diff --git a/.eslintrc.yml b/.eslintrc.yml index a8cbd9731a3dac2aa34cfe62edc15835443e0569..07542c4289140491b6531db6e37f33a392947c33 100644 --- a/.eslintrc.yml +++ b/.eslintrc.yml @@ -42,17 +42,13 @@ rules: lines-between-class-members: off # Disabled for now, to make the plugin-vue 4.5 -> 5.0 update smoother vue/no-confusing-v-for-v-if: error - vue/no-unused-components: off vue/no-use-v-if-with-v-for: off vue/no-v-html: off vue/use-v-on-exact: off - no-jquery/no-animate: off # all offenses of no-jquery/no-animate-toggle are false positives ( $toast.show() ) no-jquery/no-animate-toggle: off no-jquery/no-event-shorthand: off - no-jquery/no-fade: off no-jquery/no-serialize: error - no-jquery/no-sizzle: off promise/always-return: off promise/no-callback-in-promise: off overrides: diff --git a/.gitignore b/.gitignore index d43b1908dd39aff0116a1ff75481bba797b49095..0513bcf19cc31d959cb22c2f83719095b62f3754 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ .bundle .chef .directory +.eslintcache /.envrc eslint-report.html /.gitlab_shell_secret @@ -85,3 +86,4 @@ jsdoc/ .projections.json /qa/.rakeTasks webpack-dev-server.json +/.nvimrc diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 388f3f4b3e38008ccd714fd7bff33e41a5d80930..5f0aa51a805bc769b8d68934023dd6d9b3cd98fc 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,4 +1,4 @@ -image: "registry.gitlab.com/gitlab-org/gitlab-build-images:ruby-2.6.3-golang-1.12-git-2.24-lfs-2.9-chrome-73.0-node-12.x-yarn-1.16-postgresql-9.6-graphicsmagick-1.3.33" +image: "registry.gitlab.com/gitlab-org/gitlab-build-images:ruby-2.6.5-golang-1.12-git-2.24-lfs-2.9-chrome-73.0-node-12.x-yarn-1.16-postgresql-9.6-graphicsmagick-1.3.33" stages: - sync @@ -10,7 +10,6 @@ stages: - review - qa - post-qa - - notification - pages variables: @@ -36,13 +35,13 @@ include: - local: .gitlab/ci/frontend.gitlab-ci.yml - local: .gitlab/ci/global.gitlab-ci.yml - local: .gitlab/ci/memory.gitlab-ci.yml - - local: .gitlab/ci/notifications.gitlab-ci.yml - local: .gitlab/ci/pages.gitlab-ci.yml - local: .gitlab/ci/qa.gitlab-ci.yml - local: .gitlab/ci/reports.gitlab-ci.yml - local: .gitlab/ci/rails.gitlab-ci.yml - local: .gitlab/ci/review.gitlab-ci.yml - local: .gitlab/ci/setup.gitlab-ci.yml + - local: .gitlab/ci/dev-fixtures.gitlab-ci.yml - local: .gitlab/ci/test-metadata.gitlab-ci.yml - local: .gitlab/ci/yaml.gitlab-ci.yml - local: .gitlab/ci/releases.gitlab-ci.yml diff --git a/.gitlab/CODEOWNERS b/.gitlab/CODEOWNERS index c8283326533a855c8b38ade8a2ad37a842203c66..19aa96701d89e02cb8ae185e8029ad1c91124cad 100644 --- a/.gitlab/CODEOWNERS +++ b/.gitlab/CODEOWNERS @@ -3,7 +3,8 @@ *.rake @gitlab-org/maintainers/rails-backend # Technical writing team are the default reviewers for everything in `doc/` -/doc/ @gl-docsteam +*.md @gl-docsteam +doc/ @gl-docsteam # Frontend maintainers should see everything in `app/assets/` app/assets/ @gitlab-org/maintainers/frontend diff --git a/.gitlab/ci/cache-repo.gitlab-ci.yml b/.gitlab/ci/cache-repo.gitlab-ci.yml index f856afd3a024105da7679f38ee3d98ce5c99d130..1162e98e246448026c01550e9cc059b822311824 100644 --- a/.gitlab/ci/cache-repo.gitlab-ci.yml +++ b/.gitlab/ci/cache-repo.gitlab-ci.yml @@ -18,16 +18,23 @@ # runner, or network egress charges will apply: # https://cloud.google.com/storage/pricing cache-repo: - extends: - - .only:variables_refs-canonical-dot-com-schedules image: gcr.io/google.com/cloudsdktool/cloud-sdk:alpine stage: sync allow_failure: true variables: - GIT_DEPTH: 0 + GIT_STRATEGY: none TAR_FILENAME: /tmp/gitlab-master.tar script: + - cd .. + - rm -rf $CI_PROJECT_NAME + - git clone --progress $CI_REPOSITORY_URL $CI_PROJECT_NAME + - cd $CI_PROJECT_NAME - gcloud auth activate-service-account --key-file=$CI_REPO_CACHE_CREDENTIALS - tar cf $TAR_FILENAME . - gzip $TAR_FILENAME - gsutil cp $TAR_FILENAME.gz gs://gitlab-ci-git-repo-cache/project-$CI_PROJECT_ID/gitlab-master.tar.gz + only: + variables: + - $CI_REPO_CACHE_CREDENTIALS + refs: + - schedules diff --git a/.gitlab/ci/dev-fixtures.gitlab-ci.yml b/.gitlab/ci/dev-fixtures.gitlab-ci.yml new file mode 100644 index 0000000000000000000000000000000000000000..a5dab5d8708786fd04590b949ce89d957131f844 --- /dev/null +++ b/.gitlab/ci/dev-fixtures.gitlab-ci.yml @@ -0,0 +1,30 @@ +.run-dev-fixtures: + extends: + - .only-code-rails-job-base + - .use-pg9 + stage: test + needs: ["setup-test-env"] + dependencies: ["setup-test-env"] + variables: + FIXTURE_PATH: "db/fixtures/development" + SEED_CYCLE_ANALYTICS: "true" + SEED_PRODUCTIVITY_ANALYTICS: "true" + CYCLE_ANALYTICS_ISSUE_COUNT: 1 + SIZE: 0 # number of external projects to fork, requires network connection + # SEED_NESTED_GROUPS: "false" # requires network connection + +run-dev-fixtures-foss: + extends: .run-dev-fixtures + script: + - scripts/gitaly-test-spawn + - RAILS_ENV=test bundle exec rake db:seed_fu + +run-dev-fixtures-ee: + extends: + - .only-ee + - .use-pg9-ee + - .run-dev-fixtures + script: + - scripts/gitaly-test-spawn + - cp ee/db/fixtures/development/* $FIXTURE_PATH + - RAILS_ENV=test bundle exec rake db:seed_fu diff --git a/.gitlab/ci/docs.gitlab-ci.yml b/.gitlab/ci/docs.gitlab-ci.yml index cd0e4085e10eee8750d39b6fe86bbc24594c7ae0..4acc3c7d1fe65a74e32c5e3719f2056d5ff6f73a 100644 --- a/.gitlab/ci/docs.gitlab-ci.yml +++ b/.gitlab/ci/docs.gitlab-ci.yml @@ -11,7 +11,10 @@ stage: review dependencies: [] variables: - GIT_STRATEGY: none + # We're cloning the repo instead of downloading the script for now + # because some repos are private and CI_JOB_TOKEN cannot access files. + # See https://gitlab.com/gitlab-org/gitlab/issues/191273 + GIT_DEPTH: 1 environment: name: review-docs/$DOCS_GITLAB_REPO_SUFFIX-$CI_MERGE_REQUEST_IID # DOCS_REVIEW_APPS_DOMAIN and DOCS_GITLAB_REPO_SUFFIX are CI variables @@ -19,11 +22,7 @@ url: http://docs-preview-$DOCS_GITLAB_REPO_SUFFIX-$CI_MERGE_REQUEST_IID.$DOCS_REVIEW_APPS_DOMAIN/$DOCS_GITLAB_REPO_SUFFIX on_stop: review-docs-cleanup before_script: - # We don't clone the repo by using GIT_STRATEGY: none and only download the - # single script we need here so it's much faster than cloning. - apk add --update openssl - - wget $CI_PROJECT_URL/raw/$CI_COMMIT_SHA/scripts/trigger-build-docs - - chmod 755 trigger-build-docs - gem install httparty --no-document --version 0.17.3 - gem install gitlab --no-document --version 4.13.0 @@ -32,7 +31,7 @@ review-docs-deploy: extends: .review-docs script: - - ./trigger-build-docs deploy + - ./scripts/trigger-build-docs deploy when: manual # Cleanup remote environment of gitlab-docs @@ -42,7 +41,7 @@ review-docs-cleanup: name: review-docs/$DOCS_GITLAB_REPO_SUFFIX-$CI_MERGE_REQUEST_IID action: stop script: - - ./trigger-build-docs cleanup + - ./scripts/trigger-build-docs cleanup when: manual docs lint: @@ -51,7 +50,7 @@ docs lint: - .default-retry - .default-only - .only:changes-docs - image: "registry.gitlab.com/gitlab-org/gitlab-build-images:gitlab-docs-lint" + image: "registry.gitlab.com/gitlab-org/gitlab-docs:docs-lint" stage: test dependencies: [] script: diff --git a/.gitlab/ci/frontend.gitlab-ci.yml b/.gitlab/ci/frontend.gitlab-ci.yml index 6578eec8234dda46c3214c1259967a2d396c3d3c..076de55014e629d2ef13ef9ec8fd0ab44d3f1ac4 100644 --- a/.gitlab/ci/frontend.gitlab-ci.yml +++ b/.gitlab/ci/frontend.gitlab-ci.yml @@ -1,9 +1,46 @@ +# Make sure to update all the similar conditions in other CI config files if you modify these conditions +.if-default: &if-default + if: '$CI_COMMIT_REF_NAME == "master" || $CI_COMMIT_REF_NAME =~ /^[\d-]+-stable(-ee)?$/ || $CI_COMMIT_REF_NAME =~ /^\d+-\d+-auto-deploy-\d+$/ || $CI_COMMIT_REF_NAME =~ /^security\// || $CI_MERGE_REQUEST_IID || $CI_COMMIT_TAG' + +# Make sure to update all the similar conditions in other CI config files if you modify these conditions +.if-default-ee: &if-default-ee + if: '($CI_COMMIT_REF_NAME == "master" || $CI_COMMIT_REF_NAME =~ /^[\d-]+-stable(-ee)?$/ || $CI_COMMIT_REF_NAME =~ /^\d+-\d+-auto-deploy-\d+$/ || $CI_COMMIT_REF_NAME =~ /^security\// || $CI_MERGE_REQUEST_IID || $CI_COMMIT_TAG) && $CI_PROJECT_NAME =~ /^gitlab(-ee)?$/' + +# Make sure to update all the similar conditions in other CI config files if you modify these conditions +.if-master: &if-master + if: '$CI_COMMIT_REF_NAME == "master"' + +# Make sure to update all the similar patterns in other CI config files if you modify these patterns +.code-backstage-patterns: &code-backstage-patterns + - ".gitlab/ci/**/*" + - ".{eslintignore,gitattributes,nvmrc,prettierrc,stylelintrc,yamllint}" + - ".{codeclimate,eslintrc,gitlab-ci,haml-lint,haml-lint_todo,rubocop,rubocop_todo,scss-lint}.yml" + - ".csscomb.json" + - "Dockerfile.assets" + - "*_VERSION" + - "Gemfile{,.lock}" + - "Rakefile" + - "{babel.config,jest.config}.js" + - "config.ru" + - "{package.json,yarn.lock}" + - "{,ee/}{app,bin,config,db,haml_lint,lib,locale,public,scripts,symbol,vendor}/**/*" + - "doc/api/graphql/reference/*" # Files in this folder are auto-generated + # Backstage changes + - "Dangerfile" + - "danger/**/*" + - "{,ee/}fixtures/**/*" + - "{,ee/}rubocop/**/*" + - "{,ee/}spec/**/*" + - "doc/README.md" # Some RSpec test rely on this file + .assets-compile-cache: cache: paths: - vendor/ruby/ - .yarn-cache/ - tmp/cache/assets/sprockets + - tmp/cache/babel-loader + - tmp/cache/vue-loader .gitlab:assets:compile-metadata: extends: @@ -13,10 +50,8 @@ - .default-before_script - .assets-compile-cache - .only:changes-code-backstage-qa - image: registry.gitlab.com/gitlab-org/gitlab-build-images:ruby-2.6.3-git-2.24-lfs-2.9-chrome-73.0-node-12.x-yarn-1.16-graphicsmagick-1.3.33-docker-19.03.1 - stage: test - dependencies: ["setup-test-env"] - needs: ["setup-test-env"] + image: registry.gitlab.com/gitlab-org/gitlab-build-images:ruby-2.6.5-git-2.24-lfs-2.9-chrome-73.0-node-12.x-yarn-1.16-graphicsmagick-1.3.33-docker-19.03.1 + stage: prepare services: - docker:19.03.0-dind variables: @@ -30,7 +65,7 @@ DOCKER_DRIVER: overlay2 DOCKER_HOST: tcp://docker:2375 cache: - key: "assets-compile:production:vendor_ruby:.yarn-cache:tmp_cache_assets_sprockets:v6" + key: "assets-compile:production:vendor_ruby:.yarn-cache:tmp_cache_assets_sprockets:tmp_cache_webpack:v7" artifacts: name: webpack-report expire_in: 31d @@ -86,7 +121,7 @@ gitlab:assets:compile pull-cache: # we override the max_old_space_size to prevent OOM errors NODE_OPTIONS: --max_old_space_size=3584 cache: - key: "assets-compile:v7" + key: "assets-compile:v8" artifacts: expire_in: 7d paths: @@ -108,7 +143,7 @@ compile-assets pull-push-cache foss: - master cache: policy: pull-push - key: "assets-compile:v7:foss" + key: "assets-compile:v8:foss" compile-assets pull-cache: extends: .compile-assets-metadata @@ -119,7 +154,7 @@ compile-assets pull-cache foss: extends: [".compile-assets-metadata", ".only-ee-as-if-foss"] cache: policy: pull - key: "assets-compile:v7:foss" + key: "assets-compile:v8:foss" .only-code-frontend-job-base: extends: @@ -132,7 +167,6 @@ compile-assets pull-cache foss: - .use-pg9 stage: test needs: ["setup-test-env", "compile-assets pull-cache"] - dependencies: ["setup-test-env", "compile-assets pull-cache"] .karma-base: extends: .only-code-frontend-job-base @@ -204,9 +238,10 @@ jest-foss: - .default-tags - .default-retry - .default-cache - - .default-only - - .only:changes-code-backstage stage: test + rules: + - <<: *if-master + when: on_success dependencies: [] cache: key: "$CI_JOB_NAME" @@ -237,11 +272,12 @@ webpack-dev-server: - .default-tags - .default-retry - .default-cache - - .default-only - - .only:changes-code-backstage stage: test + rules: + - <<: *if-default + changes: *code-backstage-patterns + when: on_success needs: ["setup-test-env", "compile-assets pull-cache"] - dependencies: ["setup-test-env", "compile-assets pull-cache"] variables: WEBPACK_MEMORY_TEST: "true" WEBPACK_VENDOR_DLL: "true" diff --git a/.gitlab/ci/global.gitlab-ci.yml b/.gitlab/ci/global.gitlab-ci.yml index 9ebd28c72586ec8fecb692a6da334253e8a717c9..4c407045411cf89ecd9ae06afbea38cb19355767 100644 --- a/.gitlab/ci/global.gitlab-ci.yml +++ b/.gitlab/ci/global.gitlab-ci.yml @@ -22,7 +22,7 @@ # Jobs that only need to pull cache .default-cache: cache: - key: "debian-stretch-ruby-2.6.3-node-12.x" + key: "debian-stretch-ruby-2.6.5-node-12.x" paths: - .go/pkg/mod - vendor/ruby @@ -202,7 +202,7 @@ - name: redis:alpine .use-pg10: - image: "registry.gitlab.com/gitlab-org/gitlab-build-images:ruby-2.6.3-golang-1.12-git-2.24-lfs-2.9-chrome-73.0-node-12.x-yarn-1.16-postgresql-10-graphicsmagick-1.3.33" + image: "registry.gitlab.com/gitlab-org/gitlab-build-images:ruby-2.6.5-golang-1.12-git-2.24-lfs-2.9-chrome-73.0-node-12.x-yarn-1.16-postgresql-10-graphicsmagick-1.3.33" services: - name: postgres:10.9 command: ["postgres", "-c", "fsync=off", "-c", "synchronous_commit=off", "-c", "full_page_writes=off"] @@ -213,15 +213,15 @@ - name: postgres:9.6 command: ["postgres", "-c", "fsync=off", "-c", "synchronous_commit=off", "-c", "full_page_writes=off"] - name: redis:alpine - - name: elasticsearch:5.6.12 + - name: elasticsearch:6.4.2 .use-pg10-ee: - image: "registry.gitlab.com/gitlab-org/gitlab-build-images:ruby-2.6.3-golang-1.12-git-2.24-lfs-2.9-chrome-73.0-node-12.x-yarn-1.16-postgresql-10-graphicsmagick-1.3.33" + image: "registry.gitlab.com/gitlab-org/gitlab-build-images:ruby-2.6.5-golang-1.12-git-2.24-lfs-2.9-chrome-73.0-node-12.x-yarn-1.16-postgresql-10-graphicsmagick-1.3.33" services: - name: postgres:10.9 command: ["postgres", "-c", "fsync=off", "-c", "synchronous_commit=off", "-c", "full_page_writes=off"] - name: redis:alpine - - name: elasticsearch:5.6.12 + - name: elasticsearch:6.4.2 .only-ee: only: diff --git a/.gitlab/ci/notifications.gitlab-ci.yml b/.gitlab/ci/notifications.gitlab-ci.yml deleted file mode 100644 index 4271e709f459c3c73bbe7d183f57c733d34682f0..0000000000000000000000000000000000000000 --- a/.gitlab/ci/notifications.gitlab-ci.yml +++ /dev/null @@ -1,23 +0,0 @@ -.notify: - image: ruby:2.6-alpine - stage: notification - dependencies: [] - cache: {} - before_script: - - apk update && apk add git curl bash - - source scripts/utils.sh - - source scripts/notifications.sh - - install_gitlab_gem - variables: - COMMIT_NOTES_URL: "https://${CI_SERVER_HOST}/${CI_PROJECT_PATH}/commit/${CI_COMMIT_SHA}#notes-list" - -schedule:package-and-qa:notify-failure: - extends: - - .only:variables_refs-canonical-dot-com-schedules - - .notify - script: - - 'export NOTIFICATION_MESSAGE=":skull_and_crossbones: Scheduled QA against master failed! :skull_and_crossbones: See ${CI_PIPELINE_URL}. For downstream pipelines, see ${COMMIT_NOTES_URL}"' - - 'notify_on_job_failure schedule:package-and-qa qa-master "${NOTIFICATION_MESSAGE}" ci_failing' - needs: ["schedule:package-and-qa"] - allow_failure: true - when: always diff --git a/.gitlab/ci/qa.gitlab-ci.yml b/.gitlab/ci/qa.gitlab-ci.yml index 3cb5a40a8b5d2499c211f14aab81dd9f6bf2b1b2..5a58c3f9416ead557f944bba0d97ea444dece614 100644 --- a/.gitlab/ci/qa.gitlab-ci.yml +++ b/.gitlab/ci/qa.gitlab-ci.yml @@ -1,3 +1,32 @@ +# Make sure to update all the similar conditions in other CI config files if you modify these conditions +.if-canonical-gitlab-schedule: &if-canonical-gitlab-schedule + if: '$CI_SERVER_HOST == "gitlab.com" && $CI_PROJECT_NAMESPACE == "gitlab-org" && $CI_PIPELINE_SOURCE == "schedule"' + +# Make sure to update all the similar conditions in other CI config files if you modify these conditions +.if-canonical-gitlab-merge-request: &if-canonical-gitlab-merge-request + if: '$CI_SERVER_HOST == "gitlab.com" && $CI_PROJECT_NAMESPACE == "gitlab-org" && $CI_MERGE_REQUEST_IID' + +# Make sure to update all the similar patterns in other CI config files if you modify these patterns +.code-patterns: &code-patterns + - ".gitlab/ci/**/*" + - ".{eslintignore,gitattributes,nvmrc,prettierrc,stylelintrc,yamllint}" + - ".{codeclimate,eslintrc,gitlab-ci,haml-lint,haml-lint_todo,rubocop,rubocop_todo,scss-lint}.yml" + - ".csscomb.json" + - "Dockerfile.assets" + - "*_VERSION" + - "Gemfile{,.lock}" + - "Rakefile" + - "{babel.config,jest.config}.js" + - "config.ru" + - "{package.json,yarn.lock}" + - "{,ee/}{app,bin,config,db,haml_lint,lib,locale,public,scripts,symbol,vendor}/**/*" + - "doc/api/graphql/reference/*" # Files in this folder are auto-generated + +# Make sure to update all the similar patterns in other CI config files if you modify these patterns +.qa-patterns: &qa-patterns + - ".dockerignore" + - "qa/**/*" + .qa-job-base: extends: - .default-tags @@ -40,30 +69,16 @@ qa:selectors-foss: - install_gitlab_gem - ./scripts/trigger-build omnibus -package-and-qa-manual: - extends: - - .package-and-qa-base - - .default-only - - .only:variables-canonical-dot-com - - .except:refs-deploy - - .only:changes-code - when: manual - needs: ["build-qa-image", "gitlab:assets:compile pull-cache"] - package-and-qa: - extends: - - .package-and-qa-base - - .default-only - - .only:variables-canonical-dot-com - - .except:refs-master-tags-stable-deploy - - .only:changes-qa - needs: ["build-qa-image", "gitlab:assets:compile pull-cache"] - allow_failure: true - -schedule:package-and-qa: - extends: - - .package-and-qa-base - - .default-only - - .only:variables_refs-canonical-dot-com-schedules + extends: .package-and-qa-base + rules: + - <<: *if-canonical-gitlab-merge-request + changes: *qa-patterns + when: on_success + - <<: *if-canonical-gitlab-merge-request + changes: *code-patterns + when: manual + - <<: *if-canonical-gitlab-schedule + when: on_success needs: ["build-qa-image", "gitlab:assets:compile pull-cache"] allow_failure: true diff --git a/.gitlab/ci/rails.gitlab-ci.yml b/.gitlab/ci/rails.gitlab-ci.yml index 4ac187e1670832fb98f3da7c77f827087a97dbba..8c3df170f6dc2271a64eb1a3269d9027f76d21e9 100644 --- a/.gitlab/ci/rails.gitlab-ci.yml +++ b/.gitlab/ci/rails.gitlab-ci.yml @@ -57,7 +57,7 @@ setup-test-env: dependencies: ["setup-test-env", "retrieve-tests-metadata", "compile-assets pull-cache"] script: - source scripts/rspec_helpers.sh - - rspec_paralellized_job "--tag ~quarantine --tag ~geo" + - rspec_paralellized_job "--tag ~quarantine --tag ~geo --tag ~level:migration" artifacts: expire_in: 31d when: always @@ -92,12 +92,21 @@ setup-test-env: - .use-pg10 - .only-master +.rspec-base-migration: + script: + - source scripts/rspec_helpers.sh + - rspec_paralellized_job "--tag ~quarantine --tag ~geo --tag level:migration" + rspec migration pg9: - extends: .rspec-base-pg9 + extends: + - .rspec-base-pg9 + - .rspec-base-migration parallel: 4 rspec migration pg9-foss: - extends: .rspec-base-pg9-foss + extends: + - .rspec-base-pg9-foss + - .rspec-base-migration parallel: 4 rspec unit pg9: @@ -149,7 +158,9 @@ rspec system pg10: - .use-pg10-ee rspec-ee migration pg9: - extends: .rspec-ee-base-pg9 + extends: + - .rspec-ee-base-pg9 + - .rspec-base-migration parallel: 2 rspec-ee unit pg9: @@ -167,6 +178,7 @@ rspec-ee system pg9: rspec-ee migration pg10: extends: - .rspec-ee-base-pg10 + - .rspec-base-migration - .only-master parallel: 2 @@ -261,7 +273,7 @@ static-analysis: script: - scripts/static-analysis cache: - key: "debian-stretch-ruby-2.6.3-and-rubocop" + key: "debian-stretch-ruby-2.6-and-rubocop" paths: - vendor/ruby - tmp/rubocop_cache diff --git a/.gitlab/ci/releases.gitlab-ci.yml b/.gitlab/ci/releases.gitlab-ci.yml index d4e0236f3a8e5f9a899eec2e988c72167cf030c1..8ca4041e6bea893eb956b90243fc2eb5dbca645f 100644 --- a/.gitlab/ci/releases.gitlab-ci.yml +++ b/.gitlab/ci/releases.gitlab-ci.yml @@ -9,7 +9,7 @@ image: alpine:edge stage: sync before_script: - - apk add --no-cache --update curl bash + - apk add --no-cache --update curl bash jq after_script: [] script: - bash scripts/sync-stable-branch.sh diff --git a/.gitlab/ci/reports.gitlab-ci.yml b/.gitlab/ci/reports.gitlab-ci.yml index 4ff14b660b3320202154b894fccb3f1784368eda..81cc3e7dd2f91bdfe3250cb6623b1093d3c9d17a 100644 --- a/.gitlab/ci/reports.gitlab-ci.yml +++ b/.gitlab/ci/reports.gitlab-ci.yml @@ -20,7 +20,7 @@ code_quality: variables: DOCKER_DRIVER: overlay2 DOCKER_TLS_CERTDIR: "" - CODE_QUALITY_IMAGE: "registry.gitlab.com/gitlab-org/security-products/codequality:12-5-stable" + CODE_QUALITY_IMAGE: "registry.gitlab.com/gitlab-org/security-products/codequality:0.85.6" script: - | if ! docker info &>/dev/null; then diff --git a/.gitlab/ci/review.gitlab-ci.yml b/.gitlab/ci/review.gitlab-ci.yml index 1062f6b03a4b30ecc20b928f8a365420b781c608..ad045d6c9740b2df1bd5f15834a037a0357838d5 100644 --- a/.gitlab/ci/review.gitlab-ci.yml +++ b/.gitlab/ci/review.gitlab-ci.yml @@ -1,8 +1,34 @@ +# Make sure to update all the similar conditions in other CI config files if you modify these conditions +.if-canonical-gitlab-schedule: &if-canonical-gitlab-schedule + if: '$CI_SERVER_HOST == "gitlab.com" && $CI_PROJECT_NAMESPACE == "gitlab-org" && $CI_PIPELINE_SOURCE == "schedule"' + +# Make sure to update all the similar conditions in other CI config files if you modify these conditions +.if-canonical-gitlab-merge-request: &if-canonical-gitlab-merge-request + if: '$CI_SERVER_HOST == "gitlab.com" && $CI_PROJECT_NAMESPACE == "gitlab-org" && $CI_MERGE_REQUEST_IID' + +# Make sure to update all the similar patterns in other CI config files if you modify these patterns +.code-qa-patterns: &code-qa-patterns + - ".gitlab/ci/**/*" + - ".{eslintignore,gitattributes,nvmrc,prettierrc,stylelintrc,yamllint}" + - ".{codeclimate,eslintrc,gitlab-ci,haml-lint,haml-lint_todo,rubocop,rubocop_todo,scss-lint}.yml" + - ".csscomb.json" + - "Dockerfile.assets" + - "*_VERSION" + - "Gemfile{,.lock}" + - "Rakefile" + - "{babel.config,jest.config}.js" + - "config.ru" + - "{package.json,yarn.lock}" + - "{,ee/}{app,bin,config,db,haml_lint,lib,locale,public,scripts,symbol,vendor}/**/*" + - "doc/api/graphql/reference/*" # Files in this folder are auto-generated + # QA changes + - ".dockerignore" + - "qa/**/*" + .review-docker: extends: - .default-tags - .default-retry - - .default-only image: registry.gitlab.com/gitlab-org/gitlab-build-images:gitlab-qa-alpine services: - docker:19.03.0-dind @@ -15,12 +41,14 @@ GITLAB_EDITION: "ce" build-qa-image: - extends: - - .review-docker - - .only:variables-canonical-dot-com - - .except:refs-deploy - - .only:changes-code-qa + extends: .review-docker stage: prepare + rules: + - <<: *if-canonical-gitlab-merge-request + changes: *code-qa-patterns + when: on_success + - <<: *if-canonical-gitlab-schedule + when: on_success script: - '[[ ! -d "ee/" ]] || export GITLAB_EDITION="ee"' - export QA_MASTER_IMAGE="${CI_REGISTRY}/${CI_PROJECT_PATH}/gitlab/gitlab-${GITLAB_EDITION}-qa:master" @@ -90,7 +118,6 @@ schedule:review-build-cng: extends: - .default-tags - .default-retry - - .default-only image: registry.gitlab.com/gitlab-org/gitlab-build-images:gitlab-charts-build-base dependencies: [] variables: @@ -130,10 +157,11 @@ schedule:review-build-cng: when: always review-deploy: - extends: - - .review-deploy-base - - .only-review - - .only:changes-code-qa + extends: .review-deploy-base + rules: + - <<: *if-canonical-gitlab-merge-request + changes: *code-qa-patterns + when: on_success schedule:review-deploy: extends: @@ -141,43 +169,45 @@ schedule:review-deploy: - .only-review-schedules .base-review-stop: - extends: - - .review-workflow-base - - .only-review - - .only:changes-code-qa + extends: .review-workflow-base environment: action: stop variables: - GIT_STRATEGY: none + # We're cloning the repo instead of downloading the script for now + # because some repos are private and CI_JOB_TOKEN cannot access files. + # See https://gitlab.com/gitlab-org/gitlab/issues/191273 + GIT_DEPTH: 1 before_script: - # We don't clone the repo by using GIT_STRATEGY: none and only download the - # single script we need here so it's much faster than cloning. - apk add --update openssl - - wget $CI_PROJECT_URL/raw/$CI_COMMIT_SHA/scripts/review_apps/review-apps.sh - - wget $CI_PROJECT_URL/raw/$CI_COMMIT_SHA/scripts/utils.sh - - source utils.sh - - source review-apps.sh + - source ./scripts/utils.sh + - source ./scripts/review_apps/review-apps.sh review-stop-failed-deployment: extends: .base-review-stop stage: prepare + rules: + - <<: *if-canonical-gitlab-merge-request + changes: *code-qa-patterns + when: on_success script: - delete_failed_release review-stop: extends: .base-review-stop stage: review - when: manual + rules: + - <<: *if-canonical-gitlab-merge-request + changes: *code-qa-patterns + when: manual allow_failure: true script: - delete_release .review-qa-base: - extends: - - .review-docker - - .only-review - - .only:changes-code-qa + extends: .review-docker stage: qa + needs: ["review-deploy"] + dependencies: ["review-deploy"] allow_failure: true variables: QA_ARTIFACTS_DIR: "${CI_PROJECT_DIR}/qa" @@ -189,13 +219,6 @@ review-stop: GITLAB_ADMIN_PASSWORD: "${REVIEW_APPS_ROOT_PASSWORD}" GITHUB_ACCESS_TOKEN: "${REVIEW_APPS_QA_GITHUB_ACCESS_TOKEN}" EE_LICENSE: "${REVIEW_APPS_EE_LICENSE}" - needs: ["review-deploy"] - dependencies: ["review-deploy"] - artifacts: - paths: - - ./qa/gitlab-qa-run-* - expire_in: 7 days - when: always before_script: - '[[ ! -d "ee/" ]] || export GITLAB_EDITION="ee"' - export QA_IMAGE="${CI_REGISTRY}/${CI_PROJECT_PATH}/gitlab/gitlab-${GITLAB_EDITION}-qa:${CI_COMMIT_REF_SLUG}" @@ -205,15 +228,27 @@ review-stop: - source scripts/utils.sh - install_api_client_dependencies_with_apk - gem install gitlab-qa --no-document ${GITLAB_QA_VERSION:+ --version ${GITLAB_QA_VERSION}} + artifacts: + paths: + - ./qa/gitlab-qa-run-* + expire_in: 7 days + when: always review-qa-smoke: extends: .review-qa-base + rules: + - <<: *if-canonical-gitlab-merge-request + changes: *code-qa-patterns + when: on_success script: - gitlab-qa Test::Instance::Smoke "${QA_IMAGE}" "${CI_ENVIRONMENT_URL}" review-qa-all: extends: .review-qa-base - when: manual + rules: + - <<: *if-canonical-gitlab-merge-request + changes: *code-qa-patterns + when: manual parallel: 5 script: - export KNAPSACK_REPORT_PATH=knapsack/master_report.json @@ -241,10 +276,11 @@ review-qa-all: performance: performance.json review-performance: - extends: - - .review-performance-base - - .only-review - - .only:changes-code-qa + extends: .review-performance-base + rules: + - <<: *if-canonical-gitlab-merge-request + changes: *code-qa-patterns + when: on_success needs: ["review-deploy"] dependencies: ["review-deploy"] before_script: diff --git a/.gitlab/ci/yaml.gitlab-ci.yml b/.gitlab/ci/yaml.gitlab-ci.yml index 323f94b6d04b2ccba2707a74bc2fbf2541760765..4fcf940974be0e05d5e5cda97a7344ea38c93b47 100644 --- a/.gitlab/ci/yaml.gitlab-ci.yml +++ b/.gitlab/ci/yaml.gitlab-ci.yml @@ -10,5 +10,8 @@ lint-ci-gitlab: - "**/*.yml" image: sdesbure/yamllint:latest dependencies: [] + variables: + LINT_PATHS: .gitlab-ci.yml .gitlab/ci lib/gitlab/ci/templates changelogs script: - - yamllint .gitlab-ci.yml .gitlab/ci lib/gitlab/ci/templates changelogs + - '[[ ! -d "ee/" ]] || export LINT_PATHS="$LINT_PATHS ee/changelogs"' + - yamllint $LINT_PATHS diff --git a/.gitlab/issue_templates/Coding style proposal.md b/.gitlab/issue_templates/Coding style proposal.md index a969c9b72ee84cc3da652143a9cabdf34ae79828..95f0fb5f366ae93532c6398d073d66698e5f8a54 100644 --- a/.gitlab/issue_templates/Coding style proposal.md +++ b/.gitlab/issue_templates/Coding style proposal.md @@ -5,7 +5,7 @@ Please describe the proposal and add a link to the source (for example, http://w --> - [ ] Mention the proposal in the next backend weekly call and the #backend channel to encourage contribution -- [ ] Proceed with the proposal once 50% of the maintainers have weighed in, and 80% of the votes are :+1: +- [ ] Proceed with the proposal once 50% of the maintainers have weighed in, and 80% of their votes are :+1: - [ ] Once approved, mention it again in the next backend weekly call and the #backend channel diff --git a/.gitlab/issue_templates/Feature proposal.md b/.gitlab/issue_templates/Feature proposal.md index 2d6d03c313ca0c3ce8b60e719b7bb983f33502bb..45e9c58205f1d268892358516ff1e2af8ae09ce1 100644 --- a/.gitlab/issue_templates/Feature proposal.md +++ b/.gitlab/issue_templates/Feature proposal.md @@ -6,6 +6,7 @@ <!-- Who will use this feature? If known, include any of the following: types of users (e.g. Developer), personas, or specific company roles (e.g. Release Manager). It's okay to write "Unknown" and fill this field in later. +* [Rachel (Release Manager)](https://about.gitlab.com/handbook/marketing/product-marketing/roles-personas/#rachel-release-manager) * [Parker (Product Manager)](https://about.gitlab.com/handbook/marketing/product-marketing/roles-personas/#parker-product-manager) * [Delaney (Development Team Lead)](https://about.gitlab.com/handbook/marketing/product-marketing/roles-personas/#delaney-development-team-lead) * [Sasha (Software Developer)](https://about.gitlab.com/handbook/marketing/product-marketing/roles-personas/#sasha-software-developer) diff --git a/.gitlab/issue_templates/Security developer workflow.md b/.gitlab/issue_templates/Security developer workflow.md index e06a6fb0cffcd0bebc412cbb53ca6b7db10ad0e0..1b6a1f87216bdc40040f68af12e7c3ffbacaf8c3 100644 --- a/.gitlab/issue_templates/Security developer workflow.md +++ b/.gitlab/issue_templates/Security developer workflow.md @@ -1,60 +1,59 @@ <!-- # Read me first! -Create this issue under https://dev.gitlab.org/gitlab/gitlabhq +Create this issue under https://gitlab.com/gitlab-org/security Set the title to: `Description of the original issue` --> -### Prior to starting the security release work +## Prior to starting the security release work - [ ] Read the [security process for developers] if you are not familiar with it. -- [ ] Link to the original issue adding it to the [links section](#links) -- [ ] Run `scripts/security-harness` in the CE, EE, and/or Omnibus to prevent pushing to any remote besides `dev.gitlab.org` -- [ ] Create a new branch prefixing it with `security-` -- [ ] Create a MR targeting `dev.gitlab.org` `master` -- [ ] Add a link to this issue in the original security issue on `gitlab.com`. +- [ ] Link this issue in the Security Release issue on GitLab.com. You can find this issue in the topic of the `#releases` channel. +- [ ] Add a link to the confidential `gitlab-org/gitlab` issue describing the vulnerability next to **Original issue** in the [links table](#links). +- [ ] Add a link to the confidential `gitlab-org/gitlab` Security release issue next to **Security release issue** in the [links table](#links). +- [ ] Run `scripts/security-harness` in your local repository to prevent accidentally pushing to any remote besides `gitlab.com/gitlab-org/security`. -#### Backports +## Development -- [ ] Once the MR is ready to be merged, create MRs targeting the latest 3 stable branches - - [ ] At this point, it might be easy to squash the commits from the MR into one - - You can use the script `bin/secpick` instead of the following steps, to help you cherry-picking. See the [secpick documentation] - - [ ] Create each MR targeting the stable branch `X-Y-stable`, using the "Security Release" merge request template. - - Every merge request will have its own set of TODOs, so make sure to - complete those. -- [ ] Make sure all MRs have a link in the [links section](#links) +- [ ] Create a new branch prefixing it with `security-`. +- [ ] Create a merge request targeting `master` on `gitlab.com/gitlab-org/security` and use the [Security Release merge request template]. +- [ ] Follow the same [code review process]: Assign to a reviewer, then to a maintainer. -[secpick documentation]: https://gitlab.com/gitlab-org/release/docs/blob/master/general/security/developer.md#secpick-script +After your merge request has being approved according to our [approval guidelines], you're ready to prepare the backports + +## Backports -#### Documentation and final details +- [ ] Once the MR is ready to be merged, create MRs targeting the latest 3 stable branches + * At this point, it might be easy to squash the commits from the MR into one + * You can use the script `bin/secpick` instead of the following steps, to help you cherry-picking. See the [secpick documentation] +- [ ] Create each MR targeting the stable branch `X-Y-stable`, using the [Security Release merge request template]. + * Every merge request will have its own set of TODOs, so make sure to complete those. +- [ ] Make sure all MRs are linked in the [Links section](#links) + +## Documentation and final details -- [ ] Check the topic on #releases to see when the next release is going to happen and add a link to the [links section](#links) -- [ ] Add links to this issue and your MRs in the description of the security release issue +- [ ] Ensure the [Links section](#links) is completed. - [ ] Find out the versions affected (the Git history of the files affected may help you with this) and add them to the [details section](#details) - [ ] 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 +## Summary -#### Links +### Links | Description | Link | | -------- | -------- | | Original issue | #TODO | | Security release issue | #TODO | | `master` MR | !TODO | -| `master` MR (EE) | !TODO | | `Backport X.Y` MR | !TODO | | `Backport X.Y` MR | !TODO | | `Backport X.Y` MR | !TODO | -| `Backport X.Y` MR (EE) | !TODO | -| `Backport X.Y` MR (EE) | !TODO | -| `Backport X.Y` MR (EE) | !TODO | -#### Details +### Details | Description | Details | Further details| | -------- | -------- | -------- | @@ -65,6 +64,9 @@ Set the title to: `Description of the original issue` | Thanks | | | [security process for developers]: https://gitlab.com/gitlab-org/release/docs/blob/master/general/security/developer.md -[RM list]: https://about.gitlab.com/release-managers/ +[secpick documentation]: https://gitlab.com/gitlab-org/release/docs/blob/master/general/security/developer.md#secpick-script +[security Release merge request template]: https://gitlab.com/gitlab-org/security/gitlab/blob/master/.gitlab/merge_request_templates/Security%20Release.md +[code review process]: https://docs.gitlab.com/ee/development/code_review.html +[approval guidelines]: https://docs.gitlab.com/ee/development/code_review.html#approval-guidelines /label ~security diff --git a/.gitlab/merge_request_templates/Database changes.md b/.gitlab/merge_request_templates/Database changes.md deleted file mode 100644 index 89c8c7a5d07ad41e0eaf93f13deb1504a74a23cd..0000000000000000000000000000000000000000 --- a/.gitlab/merge_request_templates/Database changes.md +++ /dev/null @@ -1,50 +0,0 @@ -## What does this MR do? - -<!-- -Describe in detail what your merge request does, why it does that, etc. Merge -requests without an adequate description will not be reviewed until one is -added. - -Please also keep this description up-to-date with any discussion that takes -place so that reviewers can understand your intent. This is especially -important if they didn't participate in the discussion. - -Make sure to remove this comment when you are done. ---> - -Add a description of your merge request here. - -## Database checklist - -- [ ] Conforms to the [database guides](https://docs.gitlab.com/ee/development/README.html#database-guides) - -When adding migrations: - -- [ ] Updated `db/schema.rb` -- [ ] Added a `down` method so the migration can be reverted -- [ ] Added the output of the migration(s) to the MR body -- [ ] Added tests for the migration in `spec/migrations` if necessary (e.g. when migrating data) -- [ ] Added rollback procedure. Include either a rollback procedure or description how to rollback changes - -When adding or modifying queries to improve performance: - -- [ ] Included data that shows the performance improvement, preferably in the form of a benchmark -- [ ] Included the output of `EXPLAIN (ANALYZE, BUFFERS)` of the relevant queries - -When adding foreign keys to existing tables: - -- [ ] Included a migration to remove orphaned rows in the source table before adding the foreign key -- [ ] Removed any instances of `dependent: ...` that may no longer be necessary - -When adding tables: - -- [ ] Ordered columns based on the [Ordering Table Columns](https://docs.gitlab.com/ee/development/ordering_table_columns.html) guidelines -- [ ] Added foreign keys to any columns pointing to data in other tables -- [ ] Added indexes for fields that are used in statements such as `WHERE`, `ORDER BY`, `GROUP BY`, and `JOIN`s - -When removing columns, tables, indexes or other structures: - -- [ ] Removed these in a post-deployment migration -- [ ] Made sure the application no longer uses (or ignores) these structures - -/label ~database ~"database::review pending" diff --git a/.gitlab/merge_request_templates/Security Release.md b/.gitlab/merge_request_templates/Security Release.md index 42314f9b2dd68a8d31937554519aefe2013710e6..cccfafe397e07b8cd49e38f3367b1ecce17c53a3 100644 --- a/.gitlab/merge_request_templates/Security Release.md +++ b/.gitlab/merge_request_templates/Security Release.md @@ -1,35 +1,37 @@ <!-- # README first! -This MR should be created on `dev.gitlab.org`. +This MR should be created on `gitlab.com/gitlab-org/security/gitlab`. See [the general developer security release guidelines](https://gitlab.com/gitlab-org/release/docs/blob/master/general/security/developer.md). -This merge request _must not_ close the corresponding security issue _unless_ it -targets master. - -When submitting a merge request for CE, a corresponding EE merge request is -always required. This makes it easier to merge security merge requests, as -manually merging CE into EE is no longer required. - --> + ## Related issues <!-- Mention the issue(s) this MR is related to --> ## Developer checklist -- [ ] Link to the developer security workflow issue on `dev.gitlab.org` -- [ ] MR targets `master`, or `X-Y-stable` for backports -- [ ] Milestone is set for the version this MR applies to -- [ ] Title of this MR is the same as for all backports +- [ ] Link this MR in the `links` section of the related issue on [GitLab Security]. +- [ ] Merge request targets `master`, or `X-Y-stable` for backports. +- [ ] Milestone is set for the version this merge request applies to. A closed milestone can be assigned via [quick actions]. +- [ ] Title of this merge request is the same as for all backports. - [ ] A [CHANGELOG entry](https://docs.gitlab.com/ee/development/changelog.html) is added without a `merge_request` value, with `type` set to `security` -- [ ] Add a link to this MR in the `links` section of related issue -- [ ] Set up an EE MR (always required for CE merge requests): EE_MR_LINK_HERE -- [ ] Assign to a reviewer (that is not a release manager) +- [ ] Assign to a reviewer and maintainer, per our [Code Review process]. +- [ ] For the MR targeting `master`: + - [ ] Ping appsec team member who created the issue and ask for a non-blocking review with `Please review this MR`. + - [ ] Ensure it's approved according to our [Approval Guidelines]. +- [ ] Merge request _must not_ close the corresponding security issue, _unless_ it targets `master`. -## Reviewer checklist +**Note:** Reviewer/maintainer should not be a Release Manager +## Maintainer checklist - [ ] Correct milestone is applied and the title is matching across all backports - [ ] Assigned to `@gitlab-release-tools-bot` with passing CI pipelines /label ~security + +[GitLab Security]: https://gitlab.com/gitlab-org/security/gitlab +[approval guidelines]: https://docs.gitlab.com/ee/development/code_review.html#approval-guidelines +[Code Review process]: https://docs.gitlab.com/ee/development/code_review.html +[quick actions]: https://docs.gitlab.com/ee/user/project/quick_actions.html#quick-actions-for-issues-merge-requests-and-epics diff --git a/.rubocop.yml b/.rubocop.yml index 27dce2239d8919a5865993e2a66d915d44403072..da14413deb7e50a304070fa687cc5ae0bbcbb3f6 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -155,11 +155,10 @@ Rails/ApplicationRecord: # as they need to be as decoupled from application code as possible - db/**/*.rb - lib/gitlab/background_migration/**/*.rb + - ee/lib/ee/gitlab/background_migration/**/*.rb - lib/gitlab/database/**/*.rb - spec/**/*.rb - ee/db/**/*.rb - - ee/lib/gitlab/background_migration/**/*.rb - - ee/lib/ee/gitlab/background_migration/**/*.rb - ee/spec/**/*.rb # GitLab ################################################################### @@ -233,7 +232,8 @@ RSpec/FactoriesInMigrationSpecs: - 'spec/migrations/**/*.rb' - 'ee/spec/migrations/**/*.rb' - 'spec/lib/gitlab/background_migration/**/*.rb' - - 'ee/spec/lib/gitlab/background_migration/**/*.rb' + - 'spec/lib/ee/gitlab/background_migration/**/*.rb' + - 'ee/spec/lib/ee/gitlab/background_migration/**/*.rb' Cop/IncludeActionViewContext: Enabled: true @@ -299,3 +299,73 @@ Graphql/Descriptions: RSpec/AnyInstanceOf: Enabled: false + +# Cops for upgrade to gitlab-styles 3.1.0 +Rails/SafeNavigationWithBlank: + Enabled: false + +Rails/ApplicationController: + Enabled: false + +Rails/ApplicationMailer: + Enabled: false + +Rails/RakeEnvironment: + Enabled: false + +Rails/HelperInstanceVariable: + Enabled: false + +Rails/EnumHash: + Enabled: false + +RSpec/ReceiveCounts: + Enabled: false + +RSpec/ContextMethod: + Enabled: false + +RSpec/ImplicitSubject: + Enabled: false + +RSpec/LeakyConstantDeclaration: + Enabled: false + +RSpec/EmptyLineAfterHook: + Enabled: false + +RSpec/HooksBeforeExamples: + Enabled: false + +RSpec/EmptyLineAfterExample: + Enabled: false + +RSpec/Be: + Enabled: false + +RSpec/DescribedClass: + Enabled: false + +RSpec/SharedExamples: + Enabled: false + +RSpec/EmptyLineAfterExampleGroup: + Enabled: false + +RSpec/ReceiveNever: + Enabled: false + +RSpec/MissingExampleGroupArgument: + Enabled: false + +RSpec/UnspecifiedException: + Enabled: false + +RSpec/HaveGitlabHttpStatus: + Enabled: false + +Style/MultilineWhenThen: + Enabled: false + +Style/FloatDivision: + Enabled: false diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index f0388ab79d209fbc50cfc4d87b41e6fb63490259..2a3f16683cf17d860fd38ebab657f7e542eef936 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -268,11 +268,6 @@ Naming/RescuedExceptionsVariableName: RSpec/ContextWording: Enabled: false -# Offense count: 407 -# Cop supports --auto-correct. -RSpec/EmptyLineAfterFinalLet: - Enabled: false - # Offense count: 719 # Cop supports --auto-correct. # Configuration parameters: EnforcedStyle. diff --git a/.ruby-version b/.ruby-version index ec1cf33c3f6e22d5833bed6199c520a9ee20a0fa..57cf282ebbc41ec4cd51601733bc26d60c2341d4 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -2.6.3 +2.6.5 diff --git a/CHANGELOG-EE.md b/CHANGELOG-EE.md index 70b2e6999180fe96d38301e2e30b1fd683c822d8..e2aa876e4c0982259b07ff0327d5d82b58574599 100644 --- a/CHANGELOG-EE.md +++ b/CHANGELOG-EE.md @@ -4,10 +4,6 @@ Please view this file on the master branch, on stable branches it's out of date. - No changes. -## 12.6.3 - -- No changes. - ## 12.6.2 ### Security (2 changes) @@ -236,6 +232,10 @@ Please view this file on the master branch, on stable branches it's out of date. - Remove IIFEs from jira_connect.js file. !19248 (nuwe1) +## 12.4.8 + +- No changes. + ## 12.4.5 - No changes. diff --git a/CHANGELOG.md b/CHANGELOG.md index 00d04b61690895b26ccba96f1560d0830de9ccdc..80236f8a394c88b3f638f15cafdff3751eb8e4e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,25 +9,6 @@ entry. - Fix private objects exposure when using Project Import functionality. -## 12.6.3 - -### Security (1 change) - -- Upgrade json-jwt to v1.11.0. !22440 - -### Fixed (9 changes) - -- Fix RefreshMergeRequestsService raises an exception and unnecessary sidekiq retry. !22262 -- Disable Prometheus metrics if initialization fails. !22355 -- Fix bug when trying to expose artifacts and no artifacts are produced by the job. !22378 -- Gracefully error handle CI lint errors in artifacts section. !22388 -- Fix GitLab plugins not working without hooks configured. !22409 -- Fix releases page when tag contains a slash. !22527 -- Reverts Add RBAC permissions for getting knative version. !22560 -- Remove unused keyword from EKS provision service. !22633 -- Fix CAS users being signed out repeatedly. !22704 - - ## 12.6.2 ### Security (6 changes) @@ -264,7 +245,7 @@ entry. - Skip updating LFS objects in mirror updates if repository has not changed. !21744 - Add indexes on deployments to improve environments search. !21789 -### Added (117 changes, 16 of them are from the community) +### Added (119 changes, 18 of them are from the community) - Add upvote/downvotes attributes to GraphQL Epic query. !14311 - Delete kubernetes cluster association and resources. !16954 @@ -383,6 +364,8 @@ entry. - Added migration which adds service desk username column. !21733 - Add SentryIssue table to store a link between issue and sentry issue. !37026 - Add path based targeting to broadcast messages. +- Add allow failure in pipeline webhook event. !20978 (Gaetan Semet) +- Add runner information in build web hook event. !20709 (Gaetan Semet) ### Other (51 changes, 28 of them are from the community) @@ -509,7 +492,7 @@ entry. - Do not display project labels that are not visible for user accessing group labels. - Standardize error response when route is missing. -### Fixed (99 changes, 14 of them are from the community) +### Fixed (100 changes, 15 of them are from the community) - Fix incorrect selection of custom templates. !17205 - Smaller width for design comments layout, truncate image title. !17547 @@ -610,6 +593,7 @@ entry. - Only allow confirmed users to run pipelines. - Fix scroll to bottom with new job log. - Fixed protected branches flash styling. +- Show tag link whenever it's a tag in chat message integration for push events and pipeline events. !18126 (Mats Estensen) ### Deprecated (2 changes) @@ -840,6 +824,13 @@ entry. - Change selects from default browser style to custom style. +## 12.4.8 + +### Security (1 change) + +- Fix private objects exposure when using Project Import functionality. + + ## 12.4.5 - No changes. diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION index b1131583c6752e882e9f2ab96d900956642ea1a8..5ce1438f0dce871f76f745b91881535e17904f0e 100644 --- a/GITALY_SERVER_VERSION +++ b/GITALY_SERVER_VERSION @@ -1 +1 @@ -1.77.1 +ab228134ba71aef8782ef97bb8acc10da202381b diff --git a/GITLAB_ELASTICSEARCH_INDEXER_VERSION b/GITLAB_ELASTICSEARCH_INDEXER_VERSION index bc80560fad66ca670bdfbd1e5c973a024d4d0325..227cea215648b1af34a87c9acf5b707fe02d2072 100644 --- a/GITLAB_ELASTICSEARCH_INDEXER_VERSION +++ b/GITLAB_ELASTICSEARCH_INDEXER_VERSION @@ -1 +1 @@ -1.5.0 +2.0.0 diff --git a/GITLAB_SHELL_VERSION b/GITLAB_SHELL_VERSION index 0719d810258fae82369ae6a0ee85c322bb8cbd93..275283a18f9b2ced98be09d2954716e1fc001dc3 100644 --- a/GITLAB_SHELL_VERSION +++ b/GITLAB_SHELL_VERSION @@ -1 +1 @@ -10.3.0 +11.0.0 diff --git a/GITLAB_WORKHORSE_VERSION b/GITLAB_WORKHORSE_VERSION index 38b5f0deaeeb659cd45ebb257e6f0454bfaf23f1..401cc8dd3e74ab63acfc42c058005985f058c880 100644 --- a/GITLAB_WORKHORSE_VERSION +++ b/GITLAB_WORKHORSE_VERSION @@ -1 +1 @@ -8.18.0 +8.19.0 diff --git a/Gemfile b/Gemfile index 2c4a5f2e816c099b22afc1d3e7984a1d2f2451fe..951ae73a318d29f37ac04d8811ebf27953401821 100644 --- a/Gemfile +++ b/Gemfile @@ -19,7 +19,7 @@ gem 'default_value_for', '~> 3.3.0' gem 'pg', '~> 1.1' gem 'rugged', '~> 0.28' -gem 'grape-path-helpers', '~> 1.1' +gem 'grape-path-helpers', '~> 1.2' gem 'faraday', '~> 0.12' gem 'marginalia', '~> 1.8.0' @@ -129,26 +129,27 @@ gem 'unf', '~> 0.1.4' gem 'seed-fu', '~> 2.3.7' # Search -gem 'elasticsearch-model', '~> 0.1.9' -gem 'elasticsearch-rails', '~> 0.1.9', require: 'elasticsearch/rails/instrumentation' -gem 'elasticsearch-api', '5.0.3' -gem 'aws-sdk' -gem 'faraday_middleware-aws-signers-v4' +gem 'elasticsearch-model', '~> 6.1' +gem 'elasticsearch-rails', '~> 6.1', require: 'elasticsearch/rails/instrumentation' +gem 'elasticsearch-api', '~> 6.8' +gem 'aws-sdk-core', '~> 3' +gem 'aws-sdk-cloudformation', '~> 1' +gem 'faraday_middleware-aws-sigv4' # Markdown and HTML processing gem 'html-pipeline', '~> 2.12' -gem 'deckar01-task_list', '2.2.1' +gem 'deckar01-task_list', '2.3.1' gem 'gitlab-markup', '~> 1.7.0' gem 'github-markup', '~> 1.7.0', require: 'github/markup' gem 'commonmarker', '~> 0.20' gem 'RedCloth', '~> 4.3.2' -gem 'rdoc', '~> 6.0' +gem 'rdoc', '~> 6.1.2' gem 'org-ruby', '~> 0.9.12' gem 'creole', '~> 0.5.0' gem 'wikicloth', '0.8.1' gem 'asciidoctor', '~> 2.0.10' gem 'asciidoctor-include-ext', '~> 0.3.1', require: false -gem 'asciidoctor-plantuml', '0.0.9' +gem 'asciidoctor-plantuml', '0.0.10' gem 'rouge', '~> 3.11.0' gem 'truncato', '~> 0.7.11' gem 'bootstrap_form', '~> 4.2.0' @@ -249,7 +250,7 @@ gem 'asana', '~> 0.9' gem 'ruby-fogbugz', '~> 0.2.1' # Kubernetes integration -gem 'kubeclient', '~> 4.4.0' +gem 'kubeclient', '~> 4.6.0' # Sanitize user input gem 'sanitize', '~> 4.6' @@ -283,7 +284,7 @@ gem 'rack-proxy', '~> 0.6.0' gem 'sassc-rails', '~> 2.1.0' gem 'uglifier', '~> 2.7.2' -gem 'addressable', '~> 2.5.2' +gem 'addressable', '~> 2.7' gem 'font-awesome-rails', '~> 4.7' gem 'gemojione', '~> 3.3' gem 'gon', '~> 6.2' @@ -301,7 +302,7 @@ gem 'sentry-raven', '~> 2.9' gem 'premailer-rails', '~> 1.10.3' # LabKit: Tracing and Correlation -gem 'gitlab-labkit', '~> 0.5' +gem 'gitlab-labkit', '0.8.0' # I18n gem 'ruby_parser', '~> 3.8', require: false @@ -366,11 +367,11 @@ group :development, :test do gem 'spring', '~> 2.0.0' gem 'spring-commands-rspec', '~> 1.0.4' - gem 'gitlab-styles', '~> 2.7', require: false + gem 'gitlab-styles', '~> 3.1.0', require: false # Pin these dependencies, otherwise a new rule could break the CI pipelines - gem 'rubocop', '~> 0.69.0' - gem 'rubocop-performance', '~> 1.1.0' - gem 'rubocop-rspec', '~> 1.22.1' + gem 'rubocop', '~> 0.74.0' + gem 'rubocop-performance', '~> 1.4.1' + gem 'rubocop-rspec', '~> 1.37.0' gem 'scss_lint', '~> 0.56.0', require: false gem 'haml_lint', '~> 0.34.0', require: false @@ -386,6 +387,10 @@ group :development, :test do gem 'simple_po_parser', '~> 1.1.2', require: false gem 'timecop', '~> 0.8.0' + + gem 'png_quantizator', '~> 0.2.1', require: false + + gem 'parallel', '~> 1.19', require: false end # Gems required in omnibus-gitlab pipeline @@ -415,7 +420,7 @@ group :test do gem 'guard-rspec' end -gem 'octokit', '~> 4.9' +gem 'octokit', '~> 4.15' gem 'mail_room', '~> 0.10.0' @@ -452,13 +457,13 @@ group :ed25519 do end # Gitaly GRPC protocol definitions -gem 'gitaly', '~> 1.73.0' +gem 'gitaly', '~> 1.81.0' gem 'grpc', '~> 1.24.0' gem 'google-protobuf', '~> 3.8.0' -gem 'toml-rb', '~> 1.0.0', require: false +gem 'toml-rb', '~> 1.0.0' # Feature toggles gem 'flipper', '~> 0.17.1' @@ -477,3 +482,8 @@ gem 'gitlab-net-dns', '~> 0.9.1' gem 'countries', '~> 3.0' gem 'retriable', '~> 3.1.2' + +gem 'liquid', '~> 4.0' + +# LRU cache +gem 'lru_redux' diff --git a/Gemfile.lock b/Gemfile.lock index 57e428ca955ec889cbff31e35aeb2ef7abc18007..0bf630b42eff88e5d88615b2e392a9568f757943 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -55,8 +55,8 @@ GEM adamantium (0.2.0) ice_nine (~> 0.11.0) memoizable (~> 0.4.0) - addressable (2.5.2) - public_suffix (>= 2.0.2, < 4.0) + addressable (2.7.0) + public_suffix (>= 2.0.2, < 5.0) aes_key_wrap (1.0.1) akismet (3.0.0) apollo_upload_server (2.0.0.beta.3) @@ -71,7 +71,7 @@ GEM asciidoctor (2.0.10) asciidoctor-include-ext (0.3.1) asciidoctor (>= 1.5.6, < 3.0.0) - asciidoctor-plantuml (0.0.9) + asciidoctor-plantuml (0.0.10) asciidoctor (>= 1.5.6, < 3.0.0) ast (2.4.0) atlassian-jwt (0.2.0) @@ -81,13 +81,15 @@ GEM attr_required (1.0.1) awesome_print (1.8.0) aws-eventstream (1.0.3) - aws-sdk (2.11.374) - aws-sdk-resources (= 2.11.374) - aws-sdk-core (2.11.374) - aws-sigv4 (~> 1.0) + aws-partitions (1.263.0) + aws-sdk-cloudformation (1.29.0) + aws-sdk-core (~> 3, >= 3.71.0) + aws-sigv4 (~> 1.1) + aws-sdk-core (3.88.0) + aws-eventstream (~> 1.0, >= 1.0.2) + aws-partitions (~> 1, >= 1.239.0) + aws-sigv4 (~> 1.1) jmespath (~> 1.0) - aws-sdk-resources (2.11.374) - aws-sdk-core (= 2.11.374) aws-sigv4 (1.1.0) aws-eventstream (~> 1.0, >= 1.0.2) axiom-types (0.1.1) @@ -117,7 +119,7 @@ GEM activemodel (>= 5.0) brakeman (4.2.1) browser (2.5.3) - builder (3.2.3) + builder (3.2.4) bullet (6.0.2) activesupport (>= 3.0.0) uniform_notifier (~> 1.11) @@ -192,7 +194,7 @@ GEM database_cleaner (1.7.0) debug_inspector (0.0.3) debugger-ruby_core_source (1.3.8) - deckar01-task_list (2.2.1) + deckar01-task_list (2.3.1) html-pipeline declarative (0.0.10) declarative-option (0.1.0) @@ -235,17 +237,17 @@ GEM doorkeeper (~> 4.3) json-jwt (~> 1.6) ed25519 (1.2.4) - elasticsearch (5.0.3) - elasticsearch-api (= 5.0.3) - elasticsearch-transport (= 5.0.3) - elasticsearch-api (5.0.3) + elasticsearch (6.8.0) + elasticsearch-api (= 6.8.0) + elasticsearch-transport (= 6.8.0) + elasticsearch-api (6.8.0) multi_json - elasticsearch-model (0.1.9) + elasticsearch-model (6.1.0) activesupport (> 3) - elasticsearch (> 0.4) + elasticsearch (> 1) hashie - elasticsearch-rails (0.1.9) - elasticsearch-transport (5.0.3) + elasticsearch-rails (6.1.0) + elasticsearch-transport (6.8.0) faraday multi_json email_reply_trimmer (0.1.6) @@ -270,15 +272,15 @@ GEM factory_bot_rails (5.1.0) factory_bot (~> 5.1.0) railties (>= 4.2.0) - faraday (0.12.2) + faraday (0.15.4) multipart-post (>= 1.2, < 3) faraday-http-cache (2.0.0) faraday (~> 0.8) faraday_middleware (0.12.2) faraday (>= 0.7.4, < 1.0) - faraday_middleware-aws-signers-v4 (0.1.7) - aws-sdk-resources (~> 2) - faraday (~> 0.9) + faraday_middleware-aws-sigv4 (0.3.0) + aws-sigv4 (~> 1.0) + faraday (>= 0.15) faraday_middleware-multi_json (0.0.6) faraday_middleware multi_json @@ -286,6 +288,9 @@ GEM fast_gettext (1.6.0) ffaker (2.10.0) ffi (1.11.3) + ffi-compiler (1.0.1) + ffi (>= 1.0.0) + rake flipper (0.17.1) flipper-active_record (0.17.1) activerecord (>= 4.2, < 7) @@ -357,12 +362,12 @@ GEM po_to_json (>= 1.0.0) rails (>= 3.2.0) git (1.5.0) - gitaly (1.73.0) + gitaly (1.81.0) grpc (~> 1.0) github-markup (1.7.0) gitlab-chronic (0.10.5) numerizer (~> 0.2) - gitlab-labkit (0.7.0) + gitlab-labkit (0.8.0) actionpack (>= 5.0.0, < 6.1.0) activesupport (>= 5.0.0, < 6.1.0) grpc (~> 1.19) @@ -379,11 +384,12 @@ GEM gitlab-puma (>= 2.7, < 5) gitlab-sidekiq-fetcher (0.5.2) sidekiq (~> 5) - gitlab-styles (2.8.0) - rubocop (~> 0.69.0) + gitlab-styles (3.1.0) + rubocop (~> 0.74.0) rubocop-gitlab-security (~> 0.1.0) - rubocop-performance (~> 1.1.0) - rubocop-rspec (~> 1.19) + rubocop-performance (~> 1.4.1) + rubocop-rails (~> 2.0) + rubocop-rspec (~> 1.36) gitlab_chronic_duration (0.10.6.2) numerizer (~> 0.2) gitlab_omniauth-ldap (2.1.1) @@ -426,7 +432,7 @@ GEM grape-entity (0.7.1) activesupport (>= 4.0) multi_json (>= 1.3.2) - grape-path-helpers (1.1.0) + grape-path-helpers (1.2.0) activesupport grape (~> 1.0) rake (~> 12) @@ -477,7 +483,7 @@ GEM tilt hangouts-chat (0.0.5) hashdiff (0.3.8) - hashie (3.5.7) + hashie (3.6.0) hashie-forbidden_attributes (0.1.1) hashie (>= 3.0) health_check (2.6.0) @@ -492,20 +498,21 @@ GEM html2text (0.2.0) nokogiri (~> 1.6) htmlentities (4.3.4) - http (3.3.0) + http (4.2.0) addressable (~> 2.3) http-cookie (~> 1.0) http-form_data (~> 2.0) - http_parser.rb (~> 0.6.0) + http-parser (~> 1.2.0) http-cookie (1.0.3) domain_name (~> 0.5) http-form_data (2.1.1) - http_parser.rb (0.6.0) + http-parser (1.2.1) + ffi-compiler (>= 1.0, < 2.0) httparty (0.16.4) mime-types (~> 3.0) multi_xml (>= 0.5.2) httpclient (2.8.3) - i18n (1.7.0) + i18n (1.7.1) concurrent-ruby (~> 1.0) i18n_data (0.8.0) icalendar (2.4.1) @@ -519,7 +526,7 @@ GEM jaeger-client (0.10.0) opentracing (~> 0.3) thrift - jaro_winkler (1.5.3) + jaro_winkler (1.5.4) jira-ruby (1.7.1) activesupport atlassian-jwt @@ -556,8 +563,8 @@ GEM kramdown (2.1.0) kramdown-parser-gfm (1.1.0) kramdown (~> 2.0) - kubeclient (4.4.0) - http (~> 3.0) + kubeclient (4.6.0) + http (>= 3.0, < 5.0) recursive-open-struct (~> 1.0, >= 1.0.4) rest-client (~> 2.0) launchy (2.4.3) @@ -577,6 +584,7 @@ GEM xml-simple licensee (8.9.2) rugged (~> 0.24) + liquid (4.0.3) listen (3.1.5) rb-fsevent (~> 0.9, >= 0.9.4) rb-inotify (~> 0.9, >= 0.9.7) @@ -587,9 +595,10 @@ GEM activesupport (>= 4) railties (>= 4) request_store (~> 1.0) - loofah (2.3.1) + loofah (2.4.0) crass (~> 1.0.2) nokogiri (>= 1.5.9) + lru_redux (1.1.0) lumberjack (1.0.13) mail (2.7.1) mini_mime (>= 0.1.1) @@ -613,9 +622,9 @@ GEM mini_portile2 (2.4.0) minitest (5.11.3) msgpack (1.3.1) - multi_json (1.13.1) + multi_json (1.14.1) multi_xml (0.6.0) - multipart-post (2.0.0) + multipart-post (2.1.1) murmurhash3 (0.1.6) mustermann (1.0.3) mustermann-grape (1.0.0) @@ -623,13 +632,13 @@ GEM nakayoshi_fork (0.0.4) nap (1.1.0) nenv (0.3.0) - net-ldap (0.16.0) + net-ldap (0.16.2) net-ntp (2.1.3) net-ssh (5.2.0) netrc (0.11.0) nio4r (2.5.2) no_proxy_fix (0.1.2) - nokogiri (1.10.5) + nokogiri (1.10.7) mini_portile2 (~> 2.4.0) nokogumbo (1.5.0) nokogiri @@ -644,7 +653,8 @@ GEM multi_json (~> 1.3) multi_xml (~> 0.5) rack (>= 1.2, < 3) - octokit (4.9.0) + octokit (4.15.0) + faraday (>= 0.9) sawyer (~> 0.8.0, >= 0.5.3) omniauth (1.9.0) hashie (>= 3.4.6, < 3.7.0) @@ -725,13 +735,14 @@ GEM rubypants (~> 0.2) orm_adapter (0.5.0) os (1.0.0) - parallel (1.17.0) - parser (2.6.3.0) + parallel (1.19.1) + parser (2.6.5.0) ast (~> 2.4.0) parslet (1.8.2) peek (1.1.0) railties (>= 4.0.0) pg (1.1.4) + png_quantizator (0.2.1) po_to_json (1.0.1) json (>= 1.6.0) premailer (1.11.1) @@ -755,7 +766,7 @@ GEM pry (~> 0.10) pry-rails (0.3.6) pry (>= 0.10.4) - public_suffix (3.1.1) + public_suffix (4.0.3) pyu-ruby-sasl (0.0.3.3) raabro (1.1.6) rack (2.0.7) @@ -763,7 +774,8 @@ GEM rack (>= 0.4) rack-attack (6.2.0) rack (>= 1.0, < 3) - rack-cors (1.0.2) + rack-cors (1.0.6) + rack (>= 1.6.0) rack-oauth2 (1.9.3) activesupport attr_required @@ -820,7 +832,7 @@ GEM ffi (>= 1.0.6) msgpack (>= 0.4.3) optimist (>= 3.0.0) - rdoc (6.0.4) + rdoc (6.1.2) re2 (1.1.1) recaptcha (4.13.1) json @@ -903,7 +915,7 @@ GEM pg rails sqlite3 - rubocop (0.69.0) + rubocop (0.74.0) jaro_winkler (~> 1.5.1) parallel (~> 1.10) parser (>= 2.6) @@ -912,10 +924,13 @@ GEM unicode-display_width (>= 1.4.0, < 1.7) rubocop-gitlab-security (0.1.1) rubocop (>= 0.51) - rubocop-performance (1.1.0) - rubocop (>= 0.67.0) - rubocop-rspec (1.22.2) - rubocop (>= 0.52.1) + rubocop-performance (1.4.1) + rubocop (>= 0.71.0) + rubocop-rails (2.4.0) + rack (>= 1.1) + rubocop (>= 0.72.0) + rubocop-rspec (1.37.0) + rubocop (>= 0.68.1) ruby-enum (0.7.2) i18n ruby-fogbugz (0.2.1) @@ -950,9 +965,9 @@ GEM sprockets (> 3.0) sprockets-rails tilt - sawyer (0.8.1) - addressable (>= 2.3.5, < 2.6) - faraday (~> 0.8, < 1.0) + sawyer (0.8.2) + addressable (>= 2.3.5) + faraday (> 0.8, < 2.0) scss_lint (0.56.0) rake (>= 0.9, < 13) sass (~> 3.5.3) @@ -1042,7 +1057,7 @@ GEM truncato (0.7.11) htmlentities (~> 4.3.1) nokogiri (>= 1.7.0, <= 2.0) - tzinfo (1.2.5) + tzinfo (1.2.6) thread_safe (~> 0.1) u2f (0.2.1) uber (0.1.0) @@ -1119,17 +1134,18 @@ DEPENDENCIES acme-client (~> 2.0.2) activerecord-explain-analyze (~> 0.1) acts-as-taggable-on (~> 6.0) - addressable (~> 2.5.2) + addressable (~> 2.7) akismet (~> 3.0) apollo_upload_server (~> 2.0.0.beta3) asana (~> 0.9) asciidoctor (~> 2.0.10) asciidoctor-include-ext (~> 0.3.1) - asciidoctor-plantuml (= 0.0.9) + asciidoctor-plantuml (= 0.0.10) atlassian-jwt (~> 0.2.0) attr_encrypted (~> 3.1.0) awesome_print - aws-sdk + aws-sdk-cloudformation (~> 1) + aws-sdk-core (~> 3) babosa (~> 1.0.2) base32 (~> 0.3.0) batch-loader (~> 1.4.0) @@ -1155,7 +1171,7 @@ DEPENDENCIES creole (~> 0.5.0) danger (~> 6.0) database_cleaner (~> 1.7.0) - deckar01-task_list (= 2.2.1) + deckar01-task_list (= 2.3.1) default_value_for (~> 3.3.0) derailed_benchmarks device_detector @@ -1167,15 +1183,15 @@ DEPENDENCIES doorkeeper (~> 4.3) doorkeeper-openid_connect (~> 1.5) ed25519 (~> 1.2) - elasticsearch-api (= 5.0.3) - elasticsearch-model (~> 0.1.9) - elasticsearch-rails (~> 0.1.9) + elasticsearch-api (~> 6.8) + elasticsearch-model (~> 6.1) + elasticsearch-rails (~> 6.1) email_reply_trimmer (~> 0.1) email_spec (~> 2.2.0) escape_utils (~> 1.1) factory_bot_rails (~> 5.1.0) faraday (~> 0.12) - faraday_middleware-aws-signers-v4 + faraday_middleware-aws-sigv4 fast_blank ffaker (~> 2.10) flipper (~> 0.17.1) @@ -1196,17 +1212,17 @@ DEPENDENCIES gettext (~> 3.2.2) gettext_i18n_rails (~> 1.8.0) gettext_i18n_rails_js (~> 1.3) - gitaly (~> 1.73.0) + gitaly (~> 1.81.0) github-markup (~> 1.7.0) gitlab-chronic (~> 0.10.5) - gitlab-labkit (~> 0.5) + gitlab-labkit (= 0.8.0) gitlab-license (~> 1.0) gitlab-markup (~> 1.7.0) gitlab-net-dns (~> 0.9.1) gitlab-puma (~> 4.3.1.gitlab.2) gitlab-puma_worker_killer (~> 0.1.1.gitlab.1) gitlab-sidekiq-fetcher (= 0.5.2) - gitlab-styles (~> 2.7) + gitlab-styles (~> 3.1.0) gitlab_chronic_duration (~> 0.10.6.2) gitlab_omniauth-ldap (~> 2.1.1) gon (~> 6.2) @@ -1215,7 +1231,7 @@ DEPENDENCIES gpgme (~> 2.0.19) grape (~> 1.1.0) grape-entity (~> 0.7.1) - grape-path-helpers (~> 1.1) + grape-path-helpers (~> 1.2) grape_logging (~> 1.7) graphiql-rails (~> 1.4.10) graphql (~> 1.9.11) @@ -1241,12 +1257,14 @@ DEPENDENCIES jwt (~> 2.1.0) kaminari (~> 1.0) knapsack (~> 1.17) - kubeclient (~> 4.4.0) + kubeclient (~> 4.6.0) letter_opener_web (~> 1.3.4) license_finder (~> 5.4) licensee (~> 8.9) + liquid (~> 4.0) lograge (~> 0.5) loofah (~> 2.2) + lru_redux mail_room (~> 0.10.0) marginalia (~> 1.8.0) memory_profiler (~> 0.9) @@ -1260,7 +1278,7 @@ DEPENDENCIES net-ssh (~> 5.2) nokogiri (~> 1.10.5) oauth2 (~> 1.4) - octokit (~> 4.9) + octokit (~> 4.15) omniauth (~> 1.8) omniauth-auth0 (~> 2.0.0) omniauth-authentiq (~> 0.3.3) @@ -1280,8 +1298,10 @@ DEPENDENCIES omniauth_crowd (~> 2.2.0) omniauth_openid_connect (~> 0.3.3) org-ruby (~> 0.9.12) + parallel (~> 1.19) peek (~> 1.1) pg (~> 1.1) + png_quantizator (~> 0.2.1) premailer-rails (~> 1.10.3) prometheus-client-mmap (~> 0.10.0) pry-byebug (~> 3.5.1) @@ -1299,7 +1319,7 @@ DEPENDENCIES raindrops (~> 0.18) rblineprof (~> 0.3.6) rbtrace (~> 0.4) - rdoc (~> 6.0) + rdoc (~> 6.1.2) re2 (~> 1.1.1) recaptcha (~> 4.11) redis (~> 4.0) @@ -1316,9 +1336,9 @@ DEPENDENCIES rspec-set (~> 0.1.3) rspec_junit_formatter rspec_profiling (~> 0.0.5) - rubocop (~> 0.69.0) - rubocop-performance (~> 1.1.0) - rubocop-rspec (~> 1.22.1) + rubocop (~> 0.74.0) + rubocop-performance (~> 1.4.1) + rubocop-rspec (~> 1.37.0) ruby-fogbugz (~> 0.2.1) ruby-prof (~> 1.0.0) ruby-progressbar diff --git a/README.md b/README.md index 95a2192a37525614995e9e50b8df8e6e1e4d78b4..b3b6695988459715b0bbec00a0f50d18a58c6ce4 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ Instructions on how to start GitLab and how to run the tests can be found in the GitLab is a Ruby on Rails application that runs on the following software: - Ubuntu/Debian/CentOS/RHEL/OpenSUSE -- Ruby (MRI) 2.6.3 +- Ruby (MRI) 2.6.5 - Git 2.8.4+ - Redis 2.8+ - PostgreSQL (preferred) or MySQL diff --git a/VERSION b/VERSION index c3ac67f45fe4fe6faf709fd78d6f19f9e05622e8..158cd7e61bacf3801d43277db28bc3b44f9be757 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -12.6.4-ee +12.7.0-pre diff --git a/app/assets/images/ext_snippet_icons/ext_snippet_icons.png b/app/assets/images/ext_snippet_icons/ext_snippet_icons.png index 20380adc4e52eed8376e2e6371e646b0f76b8c23..c864e558bfd903bc31ca0e74f8e80d7ee375742e 100644 Binary files a/app/assets/images/ext_snippet_icons/ext_snippet_icons.png and b/app/assets/images/ext_snippet_icons/ext_snippet_icons.png differ diff --git a/app/assets/images/ext_snippet_icons/logo.png b/app/assets/images/ext_snippet_icons/logo.png deleted file mode 100644 index 794c9cc2dbc9e3416c10455a10baabf94ae14fe0..0000000000000000000000000000000000000000 Binary files a/app/assets/images/ext_snippet_icons/logo.png and /dev/null differ diff --git a/app/assets/images/ext_snippet_icons/logo.svg b/app/assets/images/ext_snippet_icons/logo.svg new file mode 100644 index 0000000000000000000000000000000000000000..9cb3042213ac25c6dc59539538b66a340528c968 --- /dev/null +++ b/app/assets/images/ext_snippet_icons/logo.svg @@ -0,0 +1 @@ +<svg width="100" height="32" xmlns="http://www.w3.org/2000/svg"><g fill-rule="nonzero" fill="none"><path fill="#8C929D" d="M67.67 8.11h-2.06l.009 15.364h8.348v-1.9H67.68l-.01-13.465zM81.913 20.778a3.517 3.517 0 01-2.553 1.078c-1.57 0-2.203-.775-2.203-1.787 0-1.522 1.059-2.25 3.309-2.25.487.002.974.04 1.456.113v2.846h-.01zm-2.137-9.313a6.826 6.826 0 00-4.387 1.579l.728 1.267c.841-.492 1.872-.983 3.356-.983 1.693 0 2.44.87 2.44 2.326v.747a9.4 9.4 0 00-1.428-.114c-3.612 0-5.446 1.267-5.446 3.914 0 2.374 1.456 3.565 3.659 3.565 1.484 0 2.912-.68 3.404-1.787l.378 1.503h1.456v-7.866c-.01-2.487-1.087-4.151-4.16-4.151zM90.587 21.926c-.776 0-1.456-.094-1.967-.33v-7.102c.7-.586 1.57-1.011 2.676-1.011 1.995 0 2.76 1.408 2.76 3.687 0 3.234-1.238 4.756-3.47 4.756m.87-10.457a3.775 3.775 0 00-2.836 1.257V10.74l-.01-2.629h-2.013l.01 14.987c1.01.425 2.391.652 3.895.652 3.848 0 5.701-2.458 5.701-6.704-.01-3.356-1.72-5.578-4.746-5.578M45.228 9.776c1.825 0 3.006.605 3.772 1.22l.889-1.541c-1.2-1.06-2.827-1.627-4.567-1.627-4.387 0-7.46 2.676-7.46 8.075 0 5.654 3.319 7.857 7.11 7.857a12.083 12.083 0 004.577-.888L49.5 16.83v-1.9h-5.63v1.9h3.594l.047 4.586c-.473.236-1.286.425-2.392.425-3.045 0-5.087-1.92-5.087-5.957-.01-4.113 2.1-6.108 5.19-6.108M59.744 8.107H57.73l.01 2.582v8.916c0 2.487 1.078 4.15 4.15 4.15.416.002.83-.036 1.24-.113v-1.806c-.31.047-.624.07-.937.066-1.692 0-2.44-.87-2.44-2.326v-6.145h3.376v-1.683h-3.373l-.009-3.64h-.003zM52.608 23.474h2.014V11.75h-2.014zM52.608 10.133h2.014V8.119h-2.014z"/><path d="M31.864 17.907l-1.788-5.496-3.538-10.9a.612.612 0 00-1.16 0L21.84 12.406H10.085L6.547 1.512a.612.612 0 00-1.16 0L1.855 12.405.066 17.907c-.162.5.015 1.05.44 1.36L15.963 30.5l15.456-11.233a1.22 1.22 0 00.446-1.36" fill="#FC6D26"/><path d="M15.966 30.49l5.875-18.086H10.09z" fill="#E24329"/><path d="M15.962 30.49l-5.877-18.086H1.859z" fill="#FC6D26"/><path d="M1.852 12.41L.063 17.906c-.162.5.015 1.05.441 1.36L15.959 30.5 1.852 12.41z" fill="#FCA326"/><path d="M1.854 12.41h8.237L6.546 1.517a.612.612 0 00-1.16 0L1.854 12.41z" fill="#E24329"/><path d="M15.966 30.49l5.875-18.086h8.236z" fill="#FC6D26"/><path d="M30.074 12.41l1.79 5.496a1.219 1.219 0 01-.44 1.36L15.966 30.49l14.107-18.08z" fill="#FCA326"/><path d="M30.079 12.41H21.84L25.38 1.517a.612.612 0 011.16 0l3.539 10.893z" fill="#E24329"/></g></svg> \ No newline at end of file diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index 071ae8ca8cface5f2f1ff5e7cbe20ac9179dc817..bee079c66433d072a5e1afac321a08e89e83b35a 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -54,10 +54,15 @@ const Api = { }); }, - groupMembers(id) { + groupMembers(id, options) { const url = Api.buildUrl(this.groupMembersPath).replace(':id', encodeURIComponent(id)); - return axios.get(url); + return axios.get(url, { + params: { + per_page: DEFAULT_PER_PAGE, + ...options, + }, + }); }, // Return groups list. Filtered by query @@ -142,6 +147,12 @@ const Api = { return axios.get(url); }, + // Update a single project + updateProject(projectPath, data) { + const url = Api.buildUrl(Api.projectPath).replace(':id', encodeURIComponent(projectPath)); + return axios.put(url, data); + }, + /** * Get all projects for a forked relationship to a specified project * @param {string} projectPath - Path or ID of a project diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js index aaab217964c49a53a76b47fbbed60a7f13bade33..0e403d023df713914fed5b05c4f7aef34f1cfd1f 100644 --- a/app/assets/javascripts/awards_handler.js +++ b/app/assets/javascripts/awards_handler.js @@ -2,13 +2,13 @@ import $ from 'jquery'; import _ from 'underscore'; +import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import Cookies from 'js-cookie'; import { __ } from './locale'; import { updateTooltipTitle } from './lib/utils/common_utils'; import { isInVueNoteablePage } from './lib/utils/dom_utils'; import flash from './flash'; import axios from './lib/utils/axios_utils'; -import bp from './breakpoints'; const animationEndEventString = 'animationend webkitAnimationEnd MSAnimationEnd oAnimationEnd'; const transitionEndEventString = 'transitionend webkitTransitionEnd oTransitionEnd MSTransitionEnd'; @@ -266,7 +266,7 @@ export class AwardsHandler { top: `${$addBtn.offset().top + $addBtn.outerHeight()}px`, }; // for xs screen we position the element on center - if (bp.getBreakpointSize() === 'xs') { + if (bp.getBreakpointSize() === 'xs' || bp.getBreakpointSize() === 'sm') { css.left = '5%'; } else if (position === 'right') { css.left = `${$addBtn.offset().left - $menu.outerWidth() + 20}px`; @@ -506,6 +506,8 @@ export class AwardsHandler { const options = { scrollTop: $('.awards').offset().top - 110, }; + + // eslint-disable-next-line no-jquery/no-animate return $('body, html').animate(options, 200); } diff --git a/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js b/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js index 318b7f77c7b1c383e0d2dcb79f8595650ec01cbd..03c1b5a0169e50f3c5d9a3774b0d9d870b66ad2d 100644 --- a/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js +++ b/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js @@ -183,7 +183,7 @@ export class CopyAsGFM { } // Export CopyAsGFM as a global for rspec to access -// see /spec/features/copy_as_gfm_spec.rb +// see /spec/features/markdown/copy_as_gfm_spec.rb if (process.env.NODE_ENV !== 'production') { window.CopyAsGFM = CopyAsGFM; } diff --git a/app/assets/javascripts/behaviors/markdown/editor_extensions.js b/app/assets/javascripts/behaviors/markdown/editor_extensions.js index 8bd2145db1c08b7348bd2a1e3589b61eb3d43a5e..308e31e704703d9e1b3a962f371cdcdcbe5f04a3 100644 --- a/app/assets/javascripts/behaviors/markdown/editor_extensions.js +++ b/app/assets/javascripts/behaviors/markdown/editor_extensions.js @@ -53,7 +53,7 @@ import InlineHTML from './marks/inline_html'; // The nodes and marks referenced here transform that same HTML to GFM to be copied to the clipboard. // Every filter in lib/banzai/pipeline/gfm_pipeline.rb that generates HTML // from GFM should have a node or mark here. -// The GFM-to-HTML-to-GFM cycle is tested in spec/features/copy_as_gfm_spec.rb. +// The GFM-to-HTML-to-GFM cycle is tested in spec/features/markdown/copy_as_gfm_spec.rb. export default [ new Doc(), diff --git a/app/assets/javascripts/behaviors/markdown/paste_markdown_table.js b/app/assets/javascripts/behaviors/markdown/paste_markdown_table.js new file mode 100644 index 0000000000000000000000000000000000000000..665a72164241601e442038fbcf8ff96dcecbeed6 --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/paste_markdown_table.js @@ -0,0 +1,122 @@ +const maxColumnWidth = (rows, columnIndex) => Math.max(...rows.map(row => row[columnIndex].length)); + +export default class PasteMarkdownTable { + constructor(clipboardData) { + this.data = clipboardData; + this.columnWidths = []; + this.rows = []; + this.tableFound = this.parseTable(); + } + + isTable() { + return this.tableFound; + } + + convertToTableMarkdown() { + this.calculateColumnWidths(); + + const markdownRows = this.rows.map( + row => + // | Name | Title | Email Address | + // |--------------|-------|----------------| + // | Jane Atler | CEO | jane@acme.com | + // | John Doherty | CTO | john@acme.com | + // | Sally Smith | CFO | sally@acme.com | + `| ${row.map((column, index) => this.formatColumn(column, index)).join(' | ')} |`, + ); + + // Insert a header break (e.g. -----) to the second row + markdownRows.splice(1, 0, this.generateHeaderBreak()); + + return markdownRows.join('\n'); + } + + // Private methods below + + // To determine whether the cut data is a table, the following criteria + // must be satisfied with the clipboard data: + // + // 1. MIME types "text/plain" and "text/html" exist + // 2. The "text/html" data must have a single <table> element + // 3. The number of rows in the "text/plain" data matches that of the "text/html" data + // 4. The max number of columns in "text/plain" matches that of the "text/html" data + parseTable() { + if (!this.data.types.includes('text/html') || !this.data.types.includes('text/plain')) { + return false; + } + + const htmlData = this.data.getData('text/html'); + this.doc = new DOMParser().parseFromString(htmlData, 'text/html'); + const tables = this.doc.querySelectorAll('table'); + + // We're only looking for exactly one table. If there happens to be + // multiple tables, it's possible an application copied data into + // the clipboard that is not related to a simple table. It may also be + // complicated converting multiple tables into Markdown. + if (tables.length !== 1) { + return false; + } + + const text = this.data.getData('text/plain').trim(); + const splitRows = text.split(/[\n\u0085\u2028\u2029]|\r\n?/g); + + // Now check that the number of rows matches between HTML and text + if (this.doc.querySelectorAll('tr').length !== splitRows.length) { + return false; + } + + this.rows = splitRows.map(row => row.split('\t')); + this.normalizeRows(); + + // Check that the max number of columns in the HTML matches the number of + // columns in the text. GitHub, for example, copies a line number and the + // line itself into the HTML data. + if (!this.columnCountsMatch()) { + return false; + } + + return true; + } + + // Ensure each row has the same number of columns + normalizeRows() { + const rowLengths = this.rows.map(row => row.length); + const maxLength = Math.max(...rowLengths); + + this.rows.forEach(row => { + while (row.length < maxLength) { + row.push(''); + } + }); + } + + calculateColumnWidths() { + this.columnWidths = this.rows[0].map((_column, columnIndex) => + maxColumnWidth(this.rows, columnIndex), + ); + } + + columnCountsMatch() { + const textColumnCount = this.rows[0].length; + let htmlColumnCount = 0; + + this.doc.querySelectorAll('table tr').forEach(row => { + htmlColumnCount = Math.max(row.cells.length, htmlColumnCount); + }); + + return textColumnCount === htmlColumnCount; + } + + formatColumn(column, index) { + const spaces = Array(this.columnWidths[index] - column.length + 1).join(' '); + return column + spaces; + } + + generateHeaderBreak() { + // Add 3 dashes to line things up: there is additional spacing for the pipe characters + const dashes = this.columnWidths.map((width, index) => + Array(this.columnWidths[index] + 3).join('-'), + ); + return `|${dashes.join('|')}|`; + } +} diff --git a/app/assets/javascripts/boards/components/board.js b/app/assets/javascripts/boards/components/board.js index 8ebdfede8f73ec56aaa35c967b7cb9ce3f6adb74..a6deb656b372d61353bed6316a383c94ce09a963 100644 --- a/app/assets/javascripts/boards/components/board.js +++ b/app/assets/javascripts/boards/components/board.js @@ -3,7 +3,7 @@ import Sortable from 'sortablejs'; import Vue from 'vue'; import { GlButtonGroup, GlButton, GlTooltip } from '@gitlab/ui'; import isWipLimitsOn from 'ee_else_ce/boards/mixins/is_wip_limits'; -import { n__, s__ } from '~/locale'; +import { s__, __, sprintf } from '~/locale'; import Icon from '~/vue_shared/components/icon.vue'; import Tooltip from '~/vue_shared/directives/tooltip'; import AccessorUtilities from '../../lib/utils/accessor'; @@ -67,10 +67,13 @@ export default Vue.extend({ !this.disabled && this.list.type !== ListType.closed && this.list.type !== ListType.blank ); }, - counterTooltip() { + issuesTooltip() { const { issuesSize } = this.list; - return `${n__('%d issue', '%d issues', issuesSize)}`; + + return sprintf(__('%{issuesSize} issues'), { issuesSize }); }, + // Only needed to make karma pass. + weightCountToolTip() {}, // eslint-disable-line vue/return-in-computed-property caretTooltip() { return this.list.isExpanded ? s__('Boards|Collapse') : s__('Boards|Expand'); }, diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue index 1e54d4d6b7dd0ade5e0fcc79db2778d8dad4d0f7..ee889e0f7e0a96736c701aee46c57aff4d541253 100644 --- a/app/assets/javascripts/boards/components/board_list.vue +++ b/app/assets/javascripts/boards/components/board_list.vue @@ -256,7 +256,7 @@ export default { let toList; if (to) { const containerEl = to.closest('.js-board-list'); - toList = boardsStore.findList('id', Number(containerEl.dataset.board)); + toList = boardsStore.findList('id', Number(containerEl.dataset.board), ''); } /** diff --git a/app/assets/javascripts/boards/components/boards_selector.vue b/app/assets/javascripts/boards/components/boards_selector.vue index 5d7be0c705aec99be8948eac7d60e4f4044d2e41..eeb0fbec1ed21c8bb329f7fff7a6350b4798726b 100644 --- a/app/assets/javascripts/boards/components/boards_selector.vue +++ b/app/assets/javascripts/boards/components/boards_selector.vue @@ -9,7 +9,6 @@ import { GlDropdownItem, } from '@gitlab/ui'; -import Icon from '~/vue_shared/components/icon.vue'; import httpStatusCodes from '~/lib/utils/http_status'; import boardsStore from '../stores/boards_store'; import BoardForm from './board_form.vue'; @@ -19,7 +18,6 @@ const MIN_BOARDS_TO_VIEW_RECENT = 10; export default { name: 'BoardsSelector', components: { - Icon, BoardForm, GlLoadingIcon, GlSearchBoxByType, diff --git a/app/assets/javascripts/boards/components/modal/list.vue b/app/assets/javascripts/boards/components/modal/list.vue index 1802b5436877a966c15b0572e650cfa923a3dfe6..78e3351a79e381aff8f68497d87e333b088bbd52 100644 --- a/app/assets/javascripts/boards/components/modal/list.vue +++ b/app/assets/javascripts/boards/components/modal/list.vue @@ -1,6 +1,6 @@ <script> +import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import Icon from '~/vue_shared/components/icon.vue'; -import bp from '../../../breakpoints'; import ModalStore from '../../stores/modal_store'; import IssueCardInner from '../issue_card_inner.vue'; @@ -105,9 +105,9 @@ export default { setColumnCount() { const breakpoint = bp.getBreakpointSize(); - if (breakpoint === 'lg' || breakpoint === 'md') { + if (breakpoint === 'xl' || breakpoint === 'lg') { this.columns = 3; - } else if (breakpoint === 'sm') { + } else if (breakpoint === 'md') { this.columns = 2; } else { this.columns = 1; diff --git a/app/assets/javascripts/breakpoints.js b/app/assets/javascripts/breakpoints.js deleted file mode 100644 index 93aacba0e8e2ea9a9cac3a4a47a1ab81a03bfbd0..0000000000000000000000000000000000000000 --- a/app/assets/javascripts/breakpoints.js +++ /dev/null @@ -1,22 +0,0 @@ -export const breakpoints = { - lg: 1200, - md: 992, - sm: 768, - xs: 0, -}; - -const BreakpointInstance = { - windowWidth: () => window.innerWidth, - getBreakpointSize() { - const windowWidth = this.windowWidth(); - - const breakpoint = Object.keys(breakpoints).find(key => windowWidth > breakpoints[key]); - - return breakpoint; - }, - isDesktop() { - return ['lg', 'md'].includes(this.getBreakpointSize()); - }, -}; - -export default BreakpointInstance; diff --git a/app/assets/javascripts/clusters/clusters_bundle.js b/app/assets/javascripts/clusters/clusters_bundle.js index d990d2677a8166356731b2f359075e44056425e4..b764348eb3c1e92bb713f49da073c7e1276f2c7e 100644 --- a/app/assets/javascripts/clusters/clusters_bundle.js +++ b/app/assets/javascripts/clusters/clusters_bundle.js @@ -53,6 +53,7 @@ export default class Clusters { helpPath, ingressHelpPath, ingressDnsHelpPath, + ingressModSecurityHelpPath, environmentsHelpPath, clustersHelpPath, deployBoardsHelpPath, @@ -69,6 +70,7 @@ export default class Clusters { helpPath, ingressHelpPath, ingressDnsHelpPath, + ingressModSecurityHelpPath, environmentsHelpPath, clustersHelpPath, deployBoardsHelpPath, @@ -169,6 +171,7 @@ export default class Clusters { ingressHelpPath: this.state.ingressHelpPath, managePrometheusPath: this.state.managePrometheusPath, ingressDnsHelpPath: this.state.ingressDnsHelpPath, + ingressModSecurityHelpPath: this.state.ingressModSecurityHelpPath, cloudRunHelpPath: this.state.cloudRunHelpPath, providerType: this.state.providerType, preInstalledKnative: this.state.preInstalledKnative, diff --git a/app/assets/javascripts/clusters/components/application_row.vue b/app/assets/javascripts/clusters/components/application_row.vue index c6c8dc6352c4f087696dbb0b45c9c2f83a9f8367..7db9898396b8faf4bb2ff0926bcc16830479ca96 100644 --- a/app/assets/javascripts/clusters/components/application_row.vue +++ b/app/assets/javascripts/clusters/components/application_row.vue @@ -2,7 +2,6 @@ /* eslint-disable vue/require-default-prop */ /* eslint-disable @gitlab/vue-i18n/no-bare-strings */ import { GlLink, GlModalDirective } from '@gitlab/ui'; -import TimeagoTooltip from '../../vue_shared/components/time_ago_tooltip.vue'; import { s__, __, sprintf } from '~/locale'; import eventHub from '../event_hub'; import identicon from '../../vue_shared/components/identicon.vue'; @@ -16,7 +15,6 @@ export default { components: { loadingButton, identicon, - TimeagoTooltip, GlLink, UninstallApplicationButton, UninstallApplicationConfirmationModal, @@ -292,6 +290,7 @@ export default { disabled && 'cluster-application-disabled', ]" class="cluster-application-row gl-responsive-table-row gl-responsive-table-row-col-span" + :data-qa-selector="id" > <div class="gl-responsive-table-row-layout" role="row"> <div class="table-section append-right-8 section-align-top" role="gridcell"> @@ -383,12 +382,16 @@ export default { :disabled="disabled || installButtonDisabled" :label="installButtonLabel" class="js-cluster-application-install-button" + data-qa-selector="install_button" + :data-qa-application="id" @click="installClicked" /> <uninstall-application-button v-if="displayUninstallButton" v-gl-modal-directive="'uninstall-' + id" :status="status" + data-qa-selector="uninstall_button" + :data-qa-application="id" class="js-cluster-application-uninstall-button" /> <uninstall-application-confirmation-modal diff --git a/app/assets/javascripts/clusters/components/applications.vue b/app/assets/javascripts/clusters/components/applications.vue index a0ab20a97aa1b0c4274440829e0b80bdfc26a1d1..704515cf70c3d77584e8c97d3d41c051a0596bfd 100644 --- a/app/assets/javascripts/clusters/components/applications.vue +++ b/app/assets/javascripts/clusters/components/applications.vue @@ -19,7 +19,6 @@ import applicationRow from './application_row.vue'; import clipboardButton from '../../vue_shared/components/clipboard_button.vue'; import KnativeDomainEditor from './knative_domain_editor.vue'; import { CLUSTER_TYPE, PROVIDER_TYPE, APPLICATION_STATUS, INGRESS } from '../constants'; -import LoadingButton from '~/vue_shared/components/loading_button.vue'; import eventHub from '~/clusters/event_hub'; import CrossplaneProviderStack from './crossplane_provider_stack.vue'; @@ -27,7 +26,6 @@ export default { components: { applicationRow, clipboardButton, - LoadingButton, GlLoadingIcon, KnativeDomainEditor, CrossplaneProviderStack, @@ -58,6 +56,11 @@ export default { required: false, default: '', }, + ingressModSecurityHelpPath: { + type: String, + required: false, + default: '', + }, cloudRunHelpPath: { type: String, required: false, @@ -114,6 +117,9 @@ export default { ingressInstalled() { return this.applications.ingress.status === APPLICATION_STATUS.INSTALLED; }, + ingressEnableModsecurity() { + return this.applications.ingress.modsecurity_enabled; + }, ingressExternalEndpoint() { return this.applications.ingress.externalIp || this.applications.ingress.externalHostname; }, @@ -123,12 +129,21 @@ export default { crossplaneInstalled() { return this.applications.crossplane.status === APPLICATION_STATUS.INSTALLED; }, - enableClusterApplicationCrossplane() { - return gon.features && gon.features.enableClusterApplicationCrossplane; - }, enableClusterApplicationElasticStack() { return gon.features && gon.features.enableClusterApplicationElasticStack; }, + ingressModSecurityDescription() { + const escapedUrl = _.escape(this.ingressModSecurityHelpPath); + + return sprintf( + s__('ClusterIntegration|Learn more about %{startLink}ModSecurity%{endLink}'), + { + startLink: `<a href="${escapedUrl}" target="_blank" rel="noopener noreferrer">`, + endLink: '</a>', + }, + false, + ); + }, ingressDescription() { return sprintf( _.escape( @@ -137,9 +152,9 @@ export default { ), ), { - pricingLink: `<strong><a href="https://cloud.google.com/compute/pricing#lb" + pricingLink: `<a href="https://cloud.google.com/compute/pricing#lb" target="_blank" rel="noopener noreferrer"> - ${_.escape(s__('ClusterIntegration|pricing'))}</a></strong>`, + ${_.escape(s__('ClusterIntegration|pricing'))}</a>`, }, false, ); @@ -204,9 +219,6 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity elasticStackInstalled() { return this.applications.elastic_stack.status === APPLICATION_STATUS.INSTALLED; }, - elasticStackKibanaHostname() { - return this.applications.elastic_stack.kibana_hostname; - }, knative() { return this.applications.knative; }, @@ -313,6 +325,9 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity :request-reason="applications.ingress.requestReason" :installed="applications.ingress.installed" :install-failed="applications.ingress.installFailed" + :install-application-request-params="{ + modsecurity_enabled: applications.ingress.modsecurity_enabled, + }" :uninstallable="applications.ingress.uninstallable" :uninstall-successful="applications.ingress.uninstallSuccessful" :uninstall-failed="applications.ingress.uninstallFailed" @@ -328,6 +343,26 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity }} </p> + <template> + <div class="form-group"> + <div class="form-check form-check-inline"> + <input + v-model="applications.ingress.modsecurity_enabled" + :disabled="ingressInstalled" + type="checkbox" + autocomplete="off" + class="form-check-input" + /> + <label class="form-check-label label-bold" for="ingress-enable-modsecurity"> + {{ s__('ClusterIntegration|Enable Web Application Firewall') }} + </label> + </div> + <p class="form-text text-muted"> + <strong v-html="ingressModSecurityDescription"></strong> + </p> + </div> + </template> + <template v-if="ingressInstalled"> <div class="form-group"> <label for="ingress-endpoint">{{ s__('ClusterIntegration|Ingress Endpoint') }}</label> @@ -377,7 +412,9 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity </p> </template> <template v-if="!ingressInstalled"> - <div class="bs-callout bs-callout-info" v-html="ingressDescription"></div> + <div class="bs-callout bs-callout-info"> + <strong v-html="ingressDescription"></strong> + </div> </template> </div> </application-row> @@ -479,7 +516,6 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity </div> </application-row> <application-row - v-if="enableClusterApplicationCrossplane" id="crossplane" :logo-url="crossplaneLogo" :title="applications.crossplane.title" @@ -638,9 +674,6 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity :uninstall-successful="applications.elastic_stack.uninstallSuccessful" :uninstall-failed="applications.elastic_stack.uninstallFailed" :disabled="!helmInstalled" - :install-application-request-params="{ - kibana_hostname: applications.elastic_stack.kibana_hostname, - }" title-link="https://github.com/helm/charts/tree/master/stable/elastic-stack" > <div slot="description"> @@ -651,40 +684,6 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity ) }} </p> - - <template v-if="ingressExternalEndpoint"> - <div class="form-group"> - <label for="elastic-stack-kibana-hostname">{{ - s__('ClusterIntegration|Kibana Hostname') - }}</label> - - <div class="input-group"> - <input - v-model="applications.elastic_stack.kibana_hostname" - :readonly="elasticStackInstalled" - type="text" - class="form-control js-hostname" - /> - <span class="input-group-btn"> - <clipboard-button - :text="elasticStackKibanaHostname" - :title="s__('ClusterIntegration|Copy Kibana Hostname')" - class="js-clipboard-btn" - /> - </span> - </div> - - <p v-if="ingressInstalled" class="form-text text-muted"> - {{ - s__(`ClusterIntegration|Replace this with your own hostname if you want. - If you do so, point hostname to Ingress IP Address from above.`) - }} - <a :href="ingressDnsHelpPath" target="_blank" rel="noopener noreferrer"> - {{ __('More information') }} - </a> - </p> - </div> - </template> </div> </application-row> </div> diff --git a/app/assets/javascripts/clusters/stores/clusters_store.js b/app/assets/javascripts/clusters/stores/clusters_store.js index 35dbf95155120a3817d6c10625c0dfe04b580c26..26456fb28dbc10fd00569202cd8771c7485b5162 100644 --- a/app/assets/javascripts/clusters/stores/clusters_store.js +++ b/app/assets/javascripts/clusters/stores/clusters_store.js @@ -5,7 +5,6 @@ import { JUPYTER, KNATIVE, CERT_MANAGER, - ELASTIC_STACK, CROSSPLANE, RUNNER, APPLICATION_INSTALLED_STATUSES, @@ -52,6 +51,7 @@ export default class ClusterStore { ingress: { ...applicationInitialState, title: s__('ClusterIntegration|Ingress'), + modsecurity_enabled: false, externalIp: null, externalHostname: null, }, @@ -96,7 +96,6 @@ export default class ClusterStore { elastic_stack: { ...applicationInitialState, title: s__('ClusterIntegration|Elastic Stack'), - kibana_hostname: null, }, }, environments: [], @@ -108,6 +107,7 @@ export default class ClusterStore { helpPath, ingressHelpPath, ingressDnsHelpPath, + ingressModSecurityHelpPath, environmentsHelpPath, clustersHelpPath, deployBoardsHelpPath, @@ -116,6 +116,7 @@ export default class ClusterStore { this.state.helpPath = helpPath; this.state.ingressHelpPath = ingressHelpPath; this.state.ingressDnsHelpPath = ingressDnsHelpPath; + this.state.ingressModSecurityHelpPath = ingressModSecurityHelpPath; this.state.environmentsHelpPath = environmentsHelpPath; this.state.clustersHelpPath = clustersHelpPath; this.state.deployBoardsHelpPath = deployBoardsHelpPath; @@ -207,6 +208,8 @@ export default class ClusterStore { if (appId === INGRESS) { this.state.applications.ingress.externalIp = serverAppEntry.external_ip; this.state.applications.ingress.externalHostname = serverAppEntry.external_hostname; + this.state.applications.ingress.modsecurity_enabled = + serverAppEntry.modsecurity_enabled || this.state.applications.ingress.modsecurity_enabled; } else if (appId === CERT_MANAGER) { this.state.applications.cert_manager.email = this.state.applications.cert_manager.email || serverAppEntry.email; @@ -231,12 +234,6 @@ export default class ClusterStore { } else if (appId === RUNNER) { this.state.applications.runner.version = version; this.state.applications.runner.updateAvailable = updateAvailable; - } else if (appId === ELASTIC_STACK) { - this.state.applications.elastic_stack.kibana_hostname = this.updateHostnameIfUnset( - this.state.applications.elastic_stack.kibana_hostname, - serverAppEntry.kibana_hostname, - 'kibana', - ); } }); } diff --git a/app/assets/javascripts/commit/image_file.js b/app/assets/javascripts/commit/image_file.js index a28e17f7a56cb24063a1c07c35b59072b5a4014b..fb8b1c17407f8cda720a40fe55fda5bef12165c4 100644 --- a/app/assets/javascripts/commit/image_file.js +++ b/app/assets/javascripts/commit/image_file.js @@ -40,7 +40,10 @@ export default class ImageFile { .removeClass('active') .filter(`.${viewMode}`) .addClass('active'); + + // eslint-disable-next-line no-jquery/no-fade return $(`.view:visible:not(.${viewMode})`, this.file).fadeOut(200, () => { + // eslint-disable-next-line no-jquery/no-fade $(`.view.${viewMode}`, this.file).fadeIn(200); return this.initView(viewMode); }); diff --git a/app/assets/javascripts/commit/pipelines/pipelines_table.vue b/app/assets/javascripts/commit/pipelines/pipelines_table.vue index e5b030d4900ac4c0063d20b31fc57e7817a302da..6b0d184faecd0278d2ef7d753cb7dcad89e87986 100644 --- a/app/assets/javascripts/commit/pipelines/pipelines_table.vue +++ b/app/assets/javascripts/commit/pipelines/pipelines_table.vue @@ -1,5 +1,6 @@ <script> import { GlButton, GlLoadingIcon } from '@gitlab/ui'; +import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import PipelinesService from '~/pipelines/services/pipelines_service'; import PipelineStore from '~/pipelines/stores/pipelines_store'; import pipelinesMixin from '~/pipelines/mixins/pipelines'; @@ -7,7 +8,6 @@ import eventHub from '~/pipelines/event_hub'; import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue'; import { getParameterByName } from '~/lib/utils/common_utils'; import CIPaginationMixin from '~/vue_shared/mixins/ci_pagination_api_mixin'; -import bp from '~/breakpoints'; export default { components: { diff --git a/app/assets/javascripts/contextual_sidebar.js b/app/assets/javascripts/contextual_sidebar.js index f43b6f3d777f760cb42dc281b66e5a8df03e4b34..51879f280e0fd4df0b46d47328e263ab07dd91ac 100644 --- a/app/assets/javascripts/contextual_sidebar.js +++ b/app/assets/javascripts/contextual_sidebar.js @@ -1,13 +1,9 @@ import $ from 'jquery'; import Cookies from 'js-cookie'; import _ from 'underscore'; -import bp from './breakpoints'; +import { GlBreakpointInstance as bp, breakpoints } from '@gitlab/ui/dist/utils'; import { parseBoolean } from '~/lib/utils/common_utils'; -// NOTE: at 1200px nav sidebar should not overlap the content -// https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/24555#note_134136110 -const NAV_SIDEBAR_BREAKPOINT = 1200; - export const SIDEBAR_COLLAPSED_CLASS = 'js-sidebar-collapsed'; export default class ContextualSidebar { @@ -50,9 +46,10 @@ export default class ContextualSidebar { $(window).on('resize', () => _.debounce(this.render(), 100)); } - // TODO: use the breakpoints from breakpoints.js once they have been updated for bootstrap 4 // See documentation: https://design.gitlab.com/regions/navigation#contextual-navigation - static isDesktopBreakpoint = () => bp.windowWidth() >= NAV_SIDEBAR_BREAKPOINT; + // NOTE: at 1200px nav sidebar should not overlap the content + // https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/24555#note_134136110 + static isDesktopBreakpoint = () => bp.windowWidth() >= breakpoints.xl; static setCollapsedCookie(value) { if (!ContextualSidebar.isDesktopBreakpoint()) { return; @@ -63,12 +60,13 @@ export default class ContextualSidebar { toggleSidebarNav(show) { const breakpoint = bp.getBreakpointSize(); const dbp = ContextualSidebar.isDesktopBreakpoint(); + const supportedSizes = ['xs', 'sm', 'md']; this.$sidebar.toggleClass(SIDEBAR_COLLAPSED_CLASS, !show); this.$sidebar.toggleClass('sidebar-expanded-mobile', !dbp ? show : false); this.$overlay.toggleClass( 'mobile-nav-open', - breakpoint === 'xs' || breakpoint === 'sm' ? show : false, + supportedSizes.includes(breakpoint) ? show : false, ); this.$sidebar.removeClass('sidebar-collapsed-desktop'); } @@ -76,13 +74,14 @@ export default class ContextualSidebar { toggleCollapsedSidebar(collapsed, saveCookie) { const breakpoint = bp.getBreakpointSize(); const dbp = ContextualSidebar.isDesktopBreakpoint(); + const supportedSizes = ['xs', 'sm', 'md']; if (this.$sidebar.length) { this.$sidebar.toggleClass(`sidebar-collapsed-desktop ${SIDEBAR_COLLAPSED_CLASS}`, collapsed); this.$sidebar.toggleClass('sidebar-expanded-mobile', !dbp ? !collapsed : false); this.$page.toggleClass( 'page-with-icon-sidebar', - breakpoint === 'xs' || breakpoint === 'sm' ? true : collapsed, + supportedSizes.includes(breakpoint) ? true : collapsed, ); } diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/cluster_form_dropdown.vue b/app/assets/javascripts/create_cluster/components/cluster_form_dropdown.vue similarity index 100% rename from app/assets/javascripts/create_cluster/eks_cluster/components/cluster_form_dropdown.vue rename to app/assets/javascripts/create_cluster/components/cluster_form_dropdown.vue diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue b/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue index d04d0ff2a6d879fc3b366be758a94ec3ecf1bbea..3d389cf3db591b2f48fb7dcb239d1833996fdea3 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue +++ b/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue @@ -3,7 +3,7 @@ import { createNamespacedHelpers, mapState, mapActions } from 'vuex'; import _ from 'underscore'; import { GlFormInput, GlFormCheckbox } from '@gitlab/ui'; import { sprintf, s__ } from '~/locale'; -import ClusterFormDropdown from './cluster_form_dropdown.vue'; +import ClusterFormDropdown from '~/create_cluster/components/cluster_form_dropdown.vue'; import { KUBERNETES_VERSIONS } from '../constants'; import LoadingButton from '~/vue_shared/components/loading_button.vue'; @@ -149,11 +149,11 @@ export default { roleDropdownHelpText() { return sprintf( s__( - 'ClusterIntegration|Select the IAM Role to allow Amazon EKS and the Kubernetes control plane to manage AWS resources on your behalf. To use a new role name, first create one on %{startLink}Amazon Web Services %{externalLinkIcon} %{endLink}.', + 'ClusterIntegration|Your service role is distinct from the provision role used when authenticating. It will allow Amazon EKS and the Kubernetes control plane to manage AWS resources on your behalf. To use a new role, first create one on %{startLink}Amazon Web Services %{externalLinkIcon} %{endLink}.', ), { startLink: - '<a href="https://docs.aws.amazon.com/eks/latest/userguide/getting-started-console.html#role-create" target="_blank" rel="noopener noreferrer">', + '<a href="https://docs.aws.amazon.com/eks/latest/userguide/service_IAM_role.html#create-service-role" target="_blank" rel="noopener noreferrer">', externalLinkIcon: this.externalLinkIcon, endLink: '</a>', }, @@ -342,10 +342,9 @@ export default { :empty-text="s__('ClusterIntegration|Kubernetes version not found')" @input="setKubernetesVersion({ kubernetesVersion: $event })" /> - <p class="form-text text-muted" v-html="roleDropdownHelpText"></p> </div> <div class="form-group"> - <label class="label-bold" for="eks-role">{{ s__('ClusterIntegration|Role name') }}</label> + <label class="label-bold" for="eks-role">{{ s__('ClusterIntegration|Service role') }}</label> <cluster-form-dropdown field-id="eks-role" field-name="eks-role" @@ -353,7 +352,7 @@ export default { :items="roles" :loading="isLoadingRoles" :loading-text="s__('ClusterIntegration|Loading IAM Roles')" - :placeholder="s__('ClusterIntergation|Select role name')" + :placeholder="s__('ClusterIntergation|Select service role')" :search-field-placeholder="s__('ClusterIntegration|Search IAM Roles')" :empty-text="s__('ClusterIntegration|No IAM Roles found')" :has-errors="Boolean(loadingRolesError)" diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/service_credentials_form.vue b/app/assets/javascripts/create_cluster/eks_cluster/components/service_credentials_form.vue index 1dd4c468ae69112cb9525d1992d15b76420e2350..49a5d4657af2085c39ec2b8b1a2cea83406727af 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/components/service_credentials_form.vue +++ b/app/assets/javascripts/create_cluster/eks_cluster/components/service_credentials_form.vue @@ -82,7 +82,7 @@ export default { }; </script> <template> - <form name="service-credentials-form" @submit.prevent="createRole({ roleArn, externalId })"> + <form name="service-credentials-form"> <h2>{{ s__('ClusterIntegration|Authenticate with Amazon Web Services') }}</h2> <p> {{ @@ -136,6 +136,7 @@ export default { :disabled="submitButtonDisabled" :loading="isCreatingRole" :label="submitButtonLabel" + @click.prevent="createRole({ roleArn, externalId })" /> </form> </template> diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/index.js b/app/assets/javascripts/create_cluster/eks_cluster/store/index.js index 09fd560240de80a92b01cf15c223e3f87203ed35..8dc55506dc2855827a8956df17224f35765ae319 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/store/index.js +++ b/app/assets/javascripts/create_cluster/eks_cluster/store/index.js @@ -4,7 +4,7 @@ import * as getters from './getters'; import mutations from './mutations'; import state from './state'; -import clusterDropdownStore from './cluster_dropdown'; +import clusterDropdownStore from '~/create_cluster/store/cluster_dropdown'; import { fetchRoles, diff --git a/app/assets/javascripts/create_cluster/gke_cluster/components/gke_network_dropdown.vue b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_network_dropdown.vue new file mode 100644 index 0000000000000000000000000000000000000000..12b6070a79a1ff024e90387a18f4d2136342ed12 --- /dev/null +++ b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_network_dropdown.vue @@ -0,0 +1,53 @@ +<script> +import { createNamespacedHelpers, mapState, mapGetters, mapActions } from 'vuex'; + +import ClusterFormDropdown from '~/create_cluster/components/cluster_form_dropdown.vue'; + +const { mapState: mapDropdownState } = createNamespacedHelpers('networks'); +const { mapActions: mapSubnetworkActions } = createNamespacedHelpers('subnetworks'); + +export default { + components: { + ClusterFormDropdown, + }, + props: { + fieldName: { + type: String, + required: true, + }, + }, + computed: { + ...mapState(['selectedNetwork']), + ...mapDropdownState(['items', 'isLoadingItems', 'loadingItemsError']), + ...mapGetters(['hasZone', 'projectId', 'region']), + }, + methods: { + ...mapActions(['setNetwork', 'setSubnetwork']), + ...mapSubnetworkActions({ fetchSubnetworks: 'fetchItems' }), + setNetworkAndFetchSubnetworks(network) { + const { projectId: project, region } = this; + + this.setSubnetwork(''); + this.setNetwork(network); + this.fetchSubnetworks({ project, region, network: network.selfLink }); + }, + }, +}; +</script> +<template> + <cluster-form-dropdown + :field-name="fieldName" + :value="selectedNetwork" + :items="items" + :disabled="!hasZone" + :loading="isLoadingItems" + :has-errors="Boolean(loadingItemsError)" + :loading-text="s__('ClusterIntegration|Loading networks')" + :placeholder="s__('ClusterIntergation|Select a network')" + :search-field-placeholder="s__('ClusterIntegration|Search networks')" + :empty-text="s__('ClusterIntegration|No networks found')" + :error-message="s__('ClusterIntegration|Could not load networks')" + :disabled-text="s__('ClusterIntegration|Select a zone to choose a network')" + @input="setNetworkAndFetchSubnetworks" + /> +</template> diff --git a/app/assets/javascripts/create_cluster/gke_cluster/components/gke_subnetwork_dropdown.vue b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_subnetwork_dropdown.vue new file mode 100644 index 0000000000000000000000000000000000000000..ec7889e2907947687ecc0265ec0d07965b13bb97 --- /dev/null +++ b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_subnetwork_dropdown.vue @@ -0,0 +1,44 @@ +<script> +import { createNamespacedHelpers, mapState, mapGetters, mapActions } from 'vuex'; + +import ClusterFormDropdown from '~/create_cluster/components/cluster_form_dropdown.vue'; + +const { mapState: mapDropdownState } = createNamespacedHelpers('subnetworks'); + +export default { + components: { + ClusterFormDropdown, + }, + props: { + fieldName: { + type: String, + required: true, + }, + }, + computed: { + ...mapState(['selectedSubnetwork']), + ...mapDropdownState(['items', 'isLoadingItems', 'loadingItemsError']), + ...mapGetters(['hasNetwork']), + }, + methods: { + ...mapActions(['setSubnetwork']), + }, +}; +</script> +<template> + <cluster-form-dropdown + :field-name="fieldName" + :value="selectedSubnetwork" + :items="items" + :disabled="!hasNetwork" + :loading="isLoadingItems" + :has-errors="Boolean(loadingItemsError)" + :loading-text="s__('ClusterIntegration|Loading subnetworks')" + :placeholder="s__('ClusterIntergation|Select a subnetwork')" + :search-field-placeholder="s__('ClusterIntegration|Search subnetworks')" + :empty-text="s__('ClusterIntegration|No subnetworks found')" + :error-message="s__('ClusterIntegration|Could not load subnetworks')" + :disabled-text="s__('ClusterIntegration|Select a network to choose a subnetwork')" + @input="setSubnetwork" + /> +</template> diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/actions.js b/app/assets/javascripts/create_cluster/store/cluster_dropdown/actions.js similarity index 100% rename from app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/actions.js rename to app/assets/javascripts/create_cluster/store/cluster_dropdown/actions.js diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/getters.js b/app/assets/javascripts/create_cluster/store/cluster_dropdown/getters.js similarity index 100% rename from app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/getters.js rename to app/assets/javascripts/create_cluster/store/cluster_dropdown/getters.js diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/index.js b/app/assets/javascripts/create_cluster/store/cluster_dropdown/index.js similarity index 100% rename from app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/index.js rename to app/assets/javascripts/create_cluster/store/cluster_dropdown/index.js diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/mutation_types.js b/app/assets/javascripts/create_cluster/store/cluster_dropdown/mutation_types.js similarity index 100% rename from app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/mutation_types.js rename to app/assets/javascripts/create_cluster/store/cluster_dropdown/mutation_types.js diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/mutations.js b/app/assets/javascripts/create_cluster/store/cluster_dropdown/mutations.js similarity index 100% rename from app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/mutations.js rename to app/assets/javascripts/create_cluster/store/cluster_dropdown/mutations.js diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/state.js b/app/assets/javascripts/create_cluster/store/cluster_dropdown/state.js similarity index 100% rename from app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/state.js rename to app/assets/javascripts/create_cluster/store/cluster_dropdown/state.js diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js index 18fb57c8b4f5a4f92424dc01fe6dbe04ca06fb8e..304a0726597222bdb825afed766e49d18c979ac6 100644 --- a/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js +++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js @@ -24,7 +24,7 @@ const EMPTY_STAGE_TEXTS = { 'The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.', ), production: __( - 'The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.', + 'The total stage shows the time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.', ), }; diff --git a/app/assets/javascripts/deploy_keys/components/key.vue b/app/assets/javascripts/deploy_keys/components/key.vue index 4d36a492c1c998716d6d49805543549b7b387929..c856e380c4168f37452c4da1e6393af8e19b989d 100644 --- a/app/assets/javascripts/deploy_keys/components/key.vue +++ b/app/assets/javascripts/deploy_keys/components/key.vue @@ -115,7 +115,10 @@ export default { <div role="rowheader" class="table-mobile-header">{{ s__('DeployKeys|Deploy key') }}</div> <div class="table-mobile-content qa-key"> <strong class="title qa-key-title"> {{ deployKey.title }} </strong> - <div class="fingerprint qa-key-fingerprint">{{ deployKey.fingerprint }}</div> + <div class="fingerprint" data-qa-selector="key_md5_fingerprint"> + {{ __('MD5') }}:{{ deployKey.fingerprint }} + </div> + <div class="fingerprint">{{ __('SHA256') }}:{{ deployKey.fingerprint_sha256 }}</div> </div> </div> <div class="table-section section-30 section-wrap"> diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue index 8ea443814e9c9bc57c058c63c65ace1e2003135d..878b54f7d5378d10cd5ab10b98e6f0c933e37740 100644 --- a/app/assets/javascripts/diffs/components/app.vue +++ b/app/assets/javascripts/diffs/components/app.vue @@ -2,11 +2,11 @@ import { mapState, mapGetters, mapActions } from 'vuex'; import { GlLoadingIcon } from '@gitlab/ui'; import Mousetrap from 'mousetrap'; -import Icon from '~/vue_shared/components/icon.vue'; import { __ } from '~/locale'; import createFlash from '~/flash'; import PanelResizer from '~/vue_shared/components/panel_resizer.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { isSingleViewStyle } from '~/helpers/diffs_helper'; import eventHub from '../../notes/event_hub'; import CompareVersions from './compare_versions.vue'; import DiffFile from './diff_file.vue'; @@ -27,7 +27,6 @@ import { export default { name: 'DiffsApp', components: { - Icon, CompareVersions, DiffFile, NoChanges, @@ -95,8 +94,8 @@ export default { parseInt(localStorage.getItem(TREE_LIST_WIDTH_STORAGE_KEY), 10) || INITIAL_TREE_WIDTH; return { - assignedDiscussions: false, treeWidth, + diffFilesLength: 0, }; }, computed: { @@ -114,6 +113,7 @@ export default { numVisibleFiles: state => state.diffs.size, plainDiffPath: state => state.diffs.plainDiffPath, emailPatchPath: state => state.diffs.emailPatchPath, + retrievingBatches: state => state.diffs.retrievingBatches, }), ...mapState('diffs', ['showTreeList', 'isLoading', 'startVersion']), ...mapGetters('diffs', ['isParallelView', 'currentDiffIndex']), @@ -144,12 +144,12 @@ export default { isLimitedContainer() { return !this.showTreeList && !this.isParallelView && !this.isFluidLayout; }, - shouldSetDiscussions() { - return this.isNotesFetched && !this.assignedDiscussions && !this.isLoading; - }, }, watch: { diffViewType() { + if (this.needsReload() || this.needsFirstLoad()) { + this.refetchDiffData(); + } this.adjustView(); }, shouldShow() { @@ -163,11 +163,6 @@ export default { }, isLoading: 'adjustView', showTreeList: 'adjustView', - shouldSetDiscussions(newVal) { - if (newVal) { - this.setDiscussions(); - } - }, }, mounted() { this.setBaseConfig({ @@ -192,10 +187,24 @@ export default { }, created() { this.adjustView(); - eventHub.$once('fetchedNotesData', this.setDiscussions); eventHub.$once('fetchDiffData', this.fetchData); eventHub.$on('refetchDiffData', this.refetchDiffData); this.CENTERED_LIMITED_CONTAINER_CLASSES = CENTERED_LIMITED_CONTAINER_CLASSES; + + this.unwatchDiscussions = this.$watch( + () => `${this.diffFiles.length}:${this.$store.state.notes.discussions.length}`, + () => this.setDiscussions(), + ); + + this.unwatchRetrievingBatches = this.$watch( + () => `${this.retrievingBatches}:${this.$store.state.notes.discussions.length}`, + () => { + if (!this.retrievingBatches && this.$store.state.notes.discussions.length) { + this.unwatchDiscussions(); + this.unwatchRetrievingBatches(); + } + }, + ); }, beforeDestroy() { eventHub.$off('fetchDiffData', this.fetchData); @@ -217,7 +226,6 @@ export default { 'toggleShowTreeList', ]), refetchDiffData() { - this.assignedDiscussions = false; this.fetchData(false); }, startDiffRendering() { @@ -228,10 +236,21 @@ export default { { timeout: 1000 }, ); }, + needsReload() { + return ( + this.glFeatures.singleMrDiffView && + this.diffFiles.length && + isSingleViewStyle(this.diffFiles[0]) + ); + }, + needsFirstLoad() { + return this.glFeatures.singleMrDiffView && !this.diffFiles.length; + }, fetchData(toggleTree = true) { if (this.glFeatures.diffsBatchLoad) { this.fetchDiffFilesMeta() - .then(() => { + .then(({ real_size }) => { + this.diffFilesLength = parseInt(real_size, 10); if (toggleTree) this.hideTreeListIfJustOneFile(); this.startDiffRendering(); @@ -241,19 +260,28 @@ export default { }); this.fetchDiffFilesBatch() + .then(() => { + // Guarantee the discussions are assigned after the batch finishes. + // Just watching the length of the discussions or the diff files + // isn't enough, because with split diff loading, neither will + // change when loading the other half of the diff files. + this.setDiscussions(); + }) .then(() => this.startDiffRendering()) .catch(() => { createFlash(__('Something went wrong on our end. Please try again!')); }); } else { this.fetchDiffFiles() - .then(() => { + .then(({ real_size }) => { + this.diffFilesLength = parseInt(real_size, 10); if (toggleTree) { this.hideTreeListIfJustOneFile(); } requestIdleCallback( () => { + this.setDiscussions(); this.startRenderDiffsQueue(); }, { timeout: 1000 }, @@ -269,17 +297,13 @@ export default { } }, setDiscussions() { - if (this.shouldSetDiscussions) { - this.assignedDiscussions = true; - - requestIdleCallback( - () => - this.assignDiscussionsToDiff() - .then(this.$nextTick) - .then(this.startTaskList), - { timeout: 1000 }, - ); - } + requestIdleCallback( + () => + this.assignDiscussionsToDiff() + .then(this.$nextTick) + .then(this.startTaskList), + { timeout: 1000 }, + ); }, adjustView() { if (this.shouldShow) { @@ -337,6 +361,7 @@ export default { :merge-request-diff="mergeRequestDiff" :target-branch="targetBranch" :is-limited-container="isLimitedContainer" + :diff-files-length="diffFilesLength" /> <hidden-files-warning @@ -349,7 +374,7 @@ export default { <div :data-can-create-note="getNoteableData.current_user.can_create_note" - class="files d-flex prepend-top-default" + class="files d-flex" > <div v-show="showTreeList" diff --git a/app/assets/javascripts/diffs/components/commit_item.vue b/app/assets/javascripts/diffs/components/commit_item.vue index 43a7703f611ef1754704ff103618c1f59fc598c3..cfffccd54ebcecf1a75b4dbd5e4644ebc5addd5b 100644 --- a/app/assets/javascripts/diffs/components/commit_item.vue +++ b/app/assets/javascripts/diffs/components/commit_item.vue @@ -2,7 +2,6 @@ import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; import Icon from '~/vue_shared/components/icon.vue'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; -import CIIcon from '~/vue_shared/components/ci_icon.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import CommitPipelineStatus from '~/projects/tree/components/commit_pipeline_status_component.vue'; import initUserPopovers from '../../user_popovers'; @@ -25,7 +24,6 @@ export default { UserAvatarLink, Icon, ClipboardButton, - CIIcon, TimeAgoTooltip, CommitPipelineStatus, }, diff --git a/app/assets/javascripts/diffs/components/compare_versions.vue b/app/assets/javascripts/diffs/components/compare_versions.vue index 2e57a47f2f7711aacdb042075898802c529a4b41..63ce43a193ddb5a01c4729763799e69d5de10554 100644 --- a/app/assets/javascripts/diffs/components/compare_versions.vue +++ b/app/assets/javascripts/diffs/components/compare_versions.vue @@ -1,5 +1,4 @@ <script> -/* eslint-disable @gitlab/vue-i18n/no-bare-strings */ import { mapActions, mapGetters, mapState } from 'vuex'; import { GlTooltipDirective, GlLink, GlButton } from '@gitlab/ui'; import { __ } from '~/locale'; @@ -42,9 +41,13 @@ export default { required: false, default: false, }, + diffFilesLength: { + type: Number, + required: true, + }, }, computed: { - ...mapGetters('diffs', ['hasCollapsedFile', 'diffFilesLength']), + ...mapGetters('diffs', ['hasCollapsedFile']), ...mapState('diffs', [ 'commit', 'showTreeList', @@ -59,9 +62,6 @@ export default { showDropdowns() { return !this.commit && this.mergeRequestDiffs.length; }, - fileTreeIcon() { - return this.showTreeList ? 'collapse-left' : 'expand-left'; - }, toggleFileBrowserTitle() { return this.showTreeList ? __('Hide file browser') : __('Show file browser'); }, @@ -87,7 +87,7 @@ export default { </script> <template> - <div class="mr-version-controls border-top border-bottom"> + <div class="mr-version-controls border-top"> <div class="mr-version-menus-container content-block" :class="{ @@ -104,17 +104,17 @@ export default { :title="toggleFileBrowserTitle" @click="toggleShowTreeList" > - <icon :name="fileTreeIcon" /> + <icon name="file-tree" /> </button> <div v-if="showDropdowns" class="d-flex align-items-center compare-versions-container"> - Changes between + {{ __('Compare') }} <compare-versions-dropdown :other-versions="mergeRequestDiffs" :merge-request-version="mergeRequestDiff" :show-commit-count="true" class="mr-version-dropdown" /> - and + {{ __('and') }} <compare-versions-dropdown :other-versions="comparableDiffs" :base-version-path="baseVersionPath" diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue index 0dbff4ffceca332358eab2460b63cb353185ab90..f5051748f1001cd7fb5b73f52c6026c0cf5a5e6f 100644 --- a/app/assets/javascripts/diffs/components/diff_file.vue +++ b/app/assets/javascripts/diffs/components/diff_file.vue @@ -4,6 +4,7 @@ import _ from 'underscore'; import { GlLoadingIcon } from '@gitlab/ui'; import { __, sprintf } from '~/locale'; import createFlash from '~/flash'; +import { hasDiff } from '~/helpers/diffs_helper'; import eventHub from '../../notes/event_hub'; import DiffFileHeader from './diff_file_header.vue'; import DiffContent from './diff_content.vue'; @@ -55,12 +56,7 @@ export default { return this.isLoadingCollapsedDiff || (!this.file.renderIt && !this.isCollapsed); }, hasDiff() { - return ( - (this.file.highlighted_diff_lines && - this.file.parallel_diff_lines && - this.file.parallel_diff_lines.length > 0) || - !this.file.blob.readable_text - ); + return hasDiff(this.file); }, isFileTooLarge() { return this.file.viewer.error === diffViewerErrors.too_large; diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue index 91d374eafc026be1df6ddf60f36bbe51e3c9f0c2..e78bea789c3020a6fb3711b21c9df5dab3034876 100644 --- a/app/assets/javascripts/diffs/components/diff_file_header.vue +++ b/app/assets/javascripts/diffs/components/diff_file_header.vue @@ -1,7 +1,7 @@ <script> import _ from 'underscore'; import { mapActions, mapGetters } from 'vuex'; -import { GlButton, GlTooltipDirective, GlTooltip, GlLoadingIcon } from '@gitlab/ui'; +import { GlButton, GlTooltipDirective, GlLoadingIcon } from '@gitlab/ui'; import { polyfillSticky } from '~/lib/utils/sticky'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import Icon from '~/vue_shared/components/icon.vue'; @@ -15,7 +15,6 @@ import { scrollToElement } from '~/lib/utils/common_utils'; export default { components: { - GlTooltip, GlLoadingIcon, GlButton, ClipboardButton, @@ -124,6 +123,20 @@ export default { } return s__('MRDiff|Show full file'); }, + changedFile() { + const { + new_path: changed, + deleted_file: deleted, + new_file: tempFile, + ...diffFile + } = this.diffFile; + return { + ...diffFile, + changed: Boolean(changed), + deleted, + tempFile, + }; + }, }, mounted() { polyfillSticky(this.$refs.header); @@ -222,7 +235,7 @@ export default { <div v-if="!diffFile.submodule && addMergeRequestButtons" - class="file-actions d-none d-sm-block" + class="file-actions d-none d-sm-flex align-items-center" > <diff-stats :added-lines="diffFile.added_lines" :removed-lines="diffFile.removed_lines" /> <div class="btn-group" role="group"> diff --git a/app/assets/javascripts/diffs/components/diff_stats.vue b/app/assets/javascripts/diffs/components/diff_stats.vue index 2e5855380afb8d3ba5e15a43b5188770db773ca1..1fa1fda7bd7ffc56f9cddd4b3267682a77ebaa31 100644 --- a/app/assets/javascripts/diffs/components/diff_stats.vue +++ b/app/assets/javascripts/diffs/components/diff_stats.vue @@ -1,9 +1,7 @@ <script> -import Icon from '~/vue_shared/components/icon.vue'; import { n__ } from '~/locale'; export default { - components: { Icon }, props: { addedLines: { type: Number, @@ -21,7 +19,7 @@ export default { }, computed: { filesText() { - return n__('File', 'Files', this.diffFilesLength); + return n__('file', 'files', this.diffFilesLength); }, isCompareVersionsHeader() { return Boolean(this.diffFilesLength); @@ -39,14 +37,21 @@ export default { }" > <div v-if="diffFilesLength !== null" class="diff-stats-group"> - <icon name="doc-code" class="diff-stats-icon text-secondary" /> - <strong>{{ diffFilesLength }} {{ filesText }}</strong> + <span class="text-secondary bold">{{ diffFilesLength }} {{ filesText }}</span> </div> - <div class="diff-stats-group cgreen"> - <icon name="file-addition" class="diff-stats-icon" /> <strong>{{ addedLines }}</strong> + <div + class="diff-stats-group cgreen d-flex align-items-center" + :class="{ bold: isCompareVersionsHeader }" + > + <span>+</span> + <span class="js-file-addition-line">{{ addedLines }}</span> </div> - <div class="diff-stats-group cred"> - <icon name="file-deletion" class="diff-stats-icon" /> <strong>{{ removedLines }}</strong> + <div + class="diff-stats-group cred d-flex align-items-center" + :class="{ bold: isCompareVersionsHeader }" + > + <span>-</span> + <span class="js-file-deletion-line">{{ removedLines }}</span> </div> </div> </template> diff --git a/app/assets/javascripts/diffs/components/inline_diff_expansion_row.vue b/app/assets/javascripts/diffs/components/inline_diff_expansion_row.vue index 6e732727f42e7d0820aa2cd7b31e58a2816e81df..071a988d78919453128e8ed01590beabe446c32d 100644 --- a/app/assets/javascripts/diffs/components/inline_diff_expansion_row.vue +++ b/app/assets/javascripts/diffs/components/inline_diff_expansion_row.vue @@ -1,11 +1,9 @@ <script> -import Icon from '~/vue_shared/components/icon.vue'; import DiffExpansionCell from './diff_expansion_cell.vue'; import { MATCH_LINE_TYPE } from '../constants'; export default { components: { - Icon, DiffExpansionCell, }, props: { diff --git a/app/assets/javascripts/diffs/components/tree_list.vue b/app/assets/javascripts/diffs/components/tree_list.vue index 30be2e68e76a289aecf03b312a6c9ad725edf251..7956d05b4f10ebf8e2945777a724e3705f28b0e0 100644 --- a/app/assets/javascripts/diffs/components/tree_list.vue +++ b/app/assets/javascripts/diffs/components/tree_list.vue @@ -4,7 +4,6 @@ import { GlTooltipDirective } from '@gitlab/ui'; import { s__, sprintf } from '~/locale'; import Icon from '~/vue_shared/components/icon.vue'; import FileRow from '~/vue_shared/components/file_row.vue'; -import FileRowStats from './file_row_stats.vue'; export default { directives: { @@ -48,9 +47,6 @@ export default { return acc; }, []); }, - fileRowExtraComponent() { - return this.hideFileStats ? null : FileRowStats; - }, }, methods: { ...mapActions('diffs', ['toggleTreeOpen', 'scrollToFile']), @@ -58,8 +54,8 @@ export default { this.search = ''; }, }, - searchPlaceholder: sprintf(s__('MergeRequest|Filter files or search with %{modifier_key}+p'), { - modifier_key: /Mac/i.test(navigator.userAgent) ? 'cmd' : 'ctrl', + searchPlaceholder: sprintf(s__('MergeRequest|Search files (%{modifier_key}P)'), { + modifier_key: /Mac/i.test(navigator.userAgent) ? '⌘' : 'Ctrl+', }), }; </script> @@ -97,7 +93,6 @@ export default { :file="file" :level="0" :hide-extra-on-tree="true" - :extra-component="fileRowExtraComponent" :show-changed-icon="true" @toggleTreeOpen="toggleTreeOpen" @clickFile="scrollToFile" diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js index 992b45c97ace3271bad759903db5da386ffa4ff7..b920e0411358e1a1eb88fe46e4f261675fd441be 100644 --- a/app/assets/javascripts/diffs/store/actions.js +++ b/app/assets/javascripts/diffs/store/actions.js @@ -64,6 +64,11 @@ export const fetchDiffFiles = ({ state, commit }) => { const urlParams = { w: state.showWhitespace ? '0' : '1', }; + let returnData; + + if (state.useSingleDiffStyle) { + urlParams.view = state.diffViewType; + } commit(types.SET_LOADING, true); @@ -83,26 +88,42 @@ export const fetchDiffFiles = ({ state, commit }) => { worker.postMessage(state.diffFiles); + returnData = res.data; return Vue.nextTick(); }) - .then(handleLocationHash) + .then(() => { + handleLocationHash(); + return returnData; + }) .catch(() => worker.terminate()); }; export const fetchDiffFilesBatch = ({ commit, state }) => { + const urlParams = { + per_page: DIFFS_PER_PAGE, + w: state.showWhitespace ? '0' : '1', + }; + + if (state.useSingleDiffStyle) { + urlParams.view = state.diffViewType; + } + commit(types.SET_BATCH_LOADING, true); + commit(types.SET_RETRIEVING_BATCHES, true); const getBatch = page => axios .get(state.endpointBatch, { - params: { page, per_page: DIFFS_PER_PAGE, w: state.showWhitespace ? '0' : '1' }, + params: { ...urlParams, page }, }) .then(({ data: { pagination, diff_files } }) => { commit(types.SET_DIFF_DATA_BATCH, { diff_files }); commit(types.SET_BATCH_LOADING, false); + if (!pagination.next_page) commit(types.SET_RETRIEVING_BATCHES, false); return pagination.next_page; }) - .then(nextPage => nextPage && getBatch(nextPage)); + .then(nextPage => nextPage && getBatch(nextPage)) + .catch(() => commit(types.SET_RETRIEVING_BATCHES, false)); return getBatch() .then(handleLocationHash) @@ -131,6 +152,7 @@ export const fetchDiffFilesMeta = ({ commit, state }) => { prepareDiffData(data); worker.postMessage(data.diff_files); + return data; }) .catch(() => worker.terminate()); }; @@ -147,7 +169,10 @@ export const assignDiscussionsToDiff = ( { commit, state, rootState }, discussions = rootState.notes.discussions, ) => { - const diffPositionByLineCode = getDiffPositionByLineCode(state.diffFiles); + const diffPositionByLineCode = getDiffPositionByLineCode( + state.diffFiles, + state.useSingleDiffStyle, + ); const hash = getLocationHash(); discussions @@ -336,24 +361,23 @@ export const toggleFileDiscussions = ({ getters, dispatch }, diff) => { export const toggleFileDiscussionWrappers = ({ commit }, diff) => { const discussionWrappersExpanded = allDiscussionWrappersExpanded(diff); - let linesWithDiscussions; - if (diff.highlighted_diff_lines) { - linesWithDiscussions = diff.highlighted_diff_lines.filter(line => line.discussions.length); - } - if (diff.parallel_diff_lines) { - linesWithDiscussions = diff.parallel_diff_lines.filter( - line => - (line.left && line.left.discussions.length) || - (line.right && line.right.discussions.length), - ); - } - - if (linesWithDiscussions.length) { - linesWithDiscussions.forEach(line => { + const lineCodesWithDiscussions = new Set(); + const { parallel_diff_lines: parallelLines, highlighted_diff_lines: inlineLines } = diff; + const allLines = inlineLines.concat( + parallelLines.map(line => line.left), + parallelLines.map(line => line.right), + ); + const lineHasDiscussion = line => Boolean(line?.discussions.length); + const registerDiscussionLine = line => lineCodesWithDiscussions.add(line.line_code); + + allLines.filter(lineHasDiscussion).forEach(registerDiscussionLine); + + if (lineCodesWithDiscussions.size) { + Array.from(lineCodesWithDiscussions).forEach(lineCode => { commit(types.TOGGLE_LINE_DISCUSSIONS, { fileHash: diff.file_hash, - lineCode: line.line_code, expanded: !discussionWrappersExpanded, + lineCode, }); }); } diff --git a/app/assets/javascripts/diffs/store/getters.js b/app/assets/javascripts/diffs/store/getters.js index bc27e263bff0938457a9f4979ba886e583f2f783..c4737090a708d9aa532e7d3945b7dcc08552bc19 100644 --- a/app/assets/javascripts/diffs/store/getters.js +++ b/app/assets/javascripts/diffs/store/getters.js @@ -95,8 +95,6 @@ export const allBlobs = (state, getters) => return acc; }, []); -export const diffFilesLength = state => state.diffFiles.length; - export const getCommentFormForDiffFile = state => fileHash => state.commentForms.find(form => form.fileHash === fileHash); diff --git a/app/assets/javascripts/diffs/store/modules/diff_state.js b/app/assets/javascripts/diffs/store/modules/diff_state.js index 7366c50752c37eea6bcdbd14a78ec9d29769306d..011cd24500a32c40e17e7685691da2169b9091e6 100644 --- a/app/assets/javascripts/diffs/store/modules/diff_state.js +++ b/app/assets/javascripts/diffs/store/modules/diff_state.js @@ -9,6 +9,7 @@ const defaultViewType = INLINE_DIFF_VIEW_TYPE; export default () => ({ isLoading: true, isBatchLoading: false, + retrievingBatches: false, addedLines: null, removedLines: null, endpoint: '', diff --git a/app/assets/javascripts/diffs/store/mutation_types.js b/app/assets/javascripts/diffs/store/mutation_types.js index 5a90d78b2bcd80fc0b198f440532a8e4a7036d21..2097c8d365540ff1835678f025930f39ad4ebd08 100644 --- a/app/assets/javascripts/diffs/store/mutation_types.js +++ b/app/assets/javascripts/diffs/store/mutation_types.js @@ -1,6 +1,7 @@ export const SET_BASE_CONFIG = 'SET_BASE_CONFIG'; export const SET_LOADING = 'SET_LOADING'; export const SET_BATCH_LOADING = 'SET_BATCH_LOADING'; +export const SET_RETRIEVING_BATCHES = 'SET_RETRIEVING_BATCHES'; export const SET_DIFF_DATA = 'SET_DIFF_DATA'; export const SET_DIFF_DATA_BATCH = 'SET_DIFF_DATA_BATCH'; export const SET_DIFF_VIEW_TYPE = 'SET_DIFF_VIEW_TYPE'; diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js index 859f43b3b6d051c9dd263528cbf2fb3c88d43125..1505be1a0b2f8eba6cc53244d5f5fa0a85d8872d 100644 --- a/app/assets/javascripts/diffs/store/mutations.js +++ b/app/assets/javascripts/diffs/store/mutations.js @@ -40,27 +40,33 @@ export default { Object.assign(state, { isBatchLoading }); }, + [types.SET_RETRIEVING_BATCHES](state, retrievingBatches) { + Object.assign(state, { retrievingBatches }); + }, + [types.SET_DIFF_DATA](state, data) { + let files = state.diffFiles; + if ( - !( - gon && - gon.features && - gon.features.diffsBatchLoad && - window.location.search.indexOf('diff_id') === -1 - ) + !(gon?.features?.diffsBatchLoad && window.location.search.indexOf('diff_id') === -1) && + data.diff_files ) { - prepareDiffData(data); + files = prepareDiffData(data, files); } Object.assign(state, { ...convertObjectPropsToCamelCase(data), + diffFiles: files, }); }, [types.SET_DIFF_DATA_BATCH](state, data) { - prepareDiffData(data); + const files = prepareDiffData(data, state.diffFiles); - state.diffFiles.push(...data.diff_files); + Object.assign(state, { + ...convertObjectPropsToCamelCase(data), + diffFiles: files, + }); }, [types.RENDER_FILE](state, file) { @@ -84,11 +90,11 @@ export default { if (!diffFile) return; - if (diffFile.highlighted_diff_lines) { + if (diffFile.highlighted_diff_lines.length) { diffFile.highlighted_diff_lines.find(l => l.line_code === lineCode).hasForm = hasForm; } - if (diffFile.parallel_diff_lines) { + if (diffFile.parallel_diff_lines.length) { const line = diffFile.parallel_diff_lines.find(l => { const { left, right } = l; @@ -149,13 +155,13 @@ export default { }, [types.EXPAND_ALL_FILES](state) { - state.diffFiles = state.diffFiles.map(file => ({ - ...file, - viewer: { - ...file.viewer, - collapsed: false, - }, - })); + state.diffFiles.forEach(file => { + Object.assign(file, { + viewer: Object.assign(file.viewer, { + collapsed: false, + }), + }); + }); }, [types.SET_LINE_DISCUSSIONS_FOR_FILE](state, { discussion, diffPositionByLineCode, hash }) { @@ -173,16 +179,19 @@ export default { const mapDiscussions = (line, extraCheck = () => true) => ({ ...line, discussions: extraCheck() - ? line.discussions + ? line.discussions && + line.discussions .filter(() => !line.discussions.some(({ id }) => discussion.id === id)) .concat(lineCheck(line) ? discussion : line.discussions) : [], }); const setDiscussionsExpanded = line => { - const isLineNoteTargeted = line.discussions.some( - disc => disc.notes && disc.notes.find(note => hash === `note_${note.id}`), - ); + const isLineNoteTargeted = + line.discussions && + line.discussions.some( + disc => disc.notes && disc.notes.find(note => hash === `note_${note.id}`), + ); return { ...line, @@ -193,29 +202,29 @@ export default { }; }; - state.diffFiles = state.diffFiles.map(diffFile => { - if (diffFile.file_hash === fileHash) { - const file = { ...diffFile }; - - if (file.highlighted_diff_lines) { - file.highlighted_diff_lines = file.highlighted_diff_lines.map(line => - setDiscussionsExpanded(lineCheck(line) ? mapDiscussions(line) : line), - ); + state.diffFiles.forEach(file => { + if (file.file_hash === fileHash) { + if (file.highlighted_diff_lines.length) { + file.highlighted_diff_lines.forEach(line => { + Object.assign( + line, + setDiscussionsExpanded(lineCheck(line) ? mapDiscussions(line) : line), + ); + }); } - if (file.parallel_diff_lines) { - file.parallel_diff_lines = file.parallel_diff_lines.map(line => { + if (file.parallel_diff_lines.length) { + file.parallel_diff_lines.forEach(line => { const left = line.left && lineCheck(line.left); const right = line.right && lineCheck(line.right); if (left || right) { - return { - ...line, + Object.assign(line, { left: line.left ? setDiscussionsExpanded(mapDiscussions(line.left)) : null, right: line.right ? setDiscussionsExpanded(mapDiscussions(line.right, () => !left)) : null, - }; + }); } return line; @@ -223,15 +232,15 @@ export default { } if (!file.parallel_diff_lines || !file.highlighted_diff_lines) { - file.discussions = (file.discussions || []) + const newDiscussions = (file.discussions || []) .filter(d => d.id !== discussion.id) .concat(discussion); - } - return file; + Object.assign(file, { + discussions: newDiscussions, + }); + } } - - return diffFile; }); }, @@ -255,9 +264,9 @@ export default { [types.TOGGLE_LINE_DISCUSSIONS](state, { fileHash, lineCode, expanded }) { const selectedFile = state.diffFiles.find(f => f.file_hash === fileHash); - updateLineInFile(selectedFile, lineCode, line => - Object.assign(line, { discussionsExpanded: expanded }), - ); + updateLineInFile(selectedFile, lineCode, line => { + Object.assign(line, { discussionsExpanded: expanded }); + }); }, [types.TOGGLE_FOLDER_OPEN](state, path) { diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js index 281a0de1fc2d8c36f3d1e4db913a7f66d816b94d..b379f1fabef839873c2bfc40e299187dff6c3078 100644 --- a/app/assets/javascripts/diffs/store/utils.js +++ b/app/assets/javascripts/diffs/store/utils.js @@ -185,6 +185,7 @@ export function addContextLines(options) { * Trims the first char of the `richText` property when it's either a space or a diff symbol. * @param {Object} line * @returns {Object} + * @deprecated */ export function trimFirstCharOfLineContent(line = {}) { // eslint-disable-next-line no-param-reassign @@ -212,79 +213,171 @@ function getLineCode({ left, right }, index) { return index; } -// This prepares and optimizes the incoming diff data from the server -// by setting up incremental rendering and removing unneeded data -export function prepareDiffData(diffData) { - const filesLength = diffData.diff_files.length; - let showingLines = 0; - for (let i = 0; i < filesLength; i += 1) { - const file = diffData.diff_files[i]; - - if (file.parallel_diff_lines) { - const linesLength = file.parallel_diff_lines.length; - for (let u = 0; u < linesLength; u += 1) { - const line = file.parallel_diff_lines[u]; - - line.line_code = getLineCode(line, u); - if (line.left) { - line.left = trimFirstCharOfLineContent(line.left); - line.left.discussions = []; - line.left.hasForm = false; - } - if (line.right) { - line.right = trimFirstCharOfLineContent(line.right); - line.right.discussions = []; - line.right.hasForm = false; - } +function diffFileUniqueId(file) { + return `${file.content_sha}-${file.file_hash}`; +} + +function combineDiffFilesWithPriorFiles(files, prior = []) { + files.forEach(file => { + const id = diffFileUniqueId(file); + const oldMatch = prior.find(oldFile => diffFileUniqueId(oldFile) === id); + + if (oldMatch) { + const missingInline = !file.highlighted_diff_lines; + const missingParallel = !file.parallel_diff_lines; + + if (missingInline) { + Object.assign(file, { + highlighted_diff_lines: oldMatch.highlighted_diff_lines, + }); } - } - if (file.highlighted_diff_lines) { - 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), - discussions: [], - hasForm: false, + if (missingParallel) { + Object.assign(file, { + parallel_diff_lines: oldMatch.parallel_diff_lines, }); } - showingLines += file.parallel_diff_lines.length; + } + }); + + return files; +} + +function ensureBasicDiffFileLines(file) { + const missingInline = !file.highlighted_diff_lines; + const missingParallel = !file.parallel_diff_lines; + + Object.assign(file, { + highlighted_diff_lines: missingInline ? [] : file.highlighted_diff_lines, + parallel_diff_lines: missingParallel ? [] : file.parallel_diff_lines, + }); + + return file; +} + +function cleanRichText(text) { + return text ? text.replace(/^[+ -]/, '') : undefined; +} + +function prepareLine(line) { + return Object.assign(line, { + rich_text: cleanRichText(line.rich_text), + discussionsExpanded: true, + discussions: [], + hasForm: false, + text: undefined, + }); +} + +function prepareDiffFileLines(file) { + const inlineLines = file.highlighted_diff_lines; + const parallelLines = file.parallel_diff_lines; + let parallelLinesCount = 0; + + inlineLines.forEach(prepareLine); + + parallelLines.forEach((line, index) => { + Object.assign(line, { line_code: getLineCode(line, index) }); + + if (line.left) { + parallelLinesCount += 1; + prepareLine(line.left); } - const name = (file.viewer && file.viewer.name) || diffViewerModes.text; + if (line.right) { + parallelLinesCount += 1; + prepareLine(line.right); + } Object.assign(file, { - renderIt: showingLines < LINES_TO_BE_RENDERED_DIRECTLY, - collapsed: name === diffViewerModes.text && showingLines > MAX_LINES_TO_BE_RENDERED, - isShowingFullFile: false, - isLoadingFullFile: false, - discussions: [], - renderingLines: false, + inlineLinesCount: inlineLines.length, + parallelLinesCount, }); - } + }); + + return file; } -export function getDiffPositionByLineCode(diffFiles) { - return diffFiles.reduce((acc, diffFile) => { - // We can only use highlightedDiffLines to create the map of diff lines because - // highlightedDiffLines will also include every parallel diff line in it. - if (diffFile.highlighted_diff_lines) { +function getVisibleDiffLines(file) { + return Math.max(file.inlineLinesCount, file.parallelLinesCount); +} + +function finalizeDiffFile(file) { + const name = (file.viewer && file.viewer.name) || diffViewerModes.text; + const lines = getVisibleDiffLines(file); + + Object.assign(file, { + renderIt: lines < LINES_TO_BE_RENDERED_DIRECTLY, + collapsed: name === diffViewerModes.text && lines > MAX_LINES_TO_BE_RENDERED, + isShowingFullFile: false, + isLoadingFullFile: false, + discussions: [], + renderingLines: false, + }); + + return file; +} + +export function prepareDiffData(diffData, priorFiles) { + return combineDiffFilesWithPriorFiles(diffData.diff_files, priorFiles) + .map(ensureBasicDiffFileLines) + .map(prepareDiffFileLines) + .map(finalizeDiffFile); +} + +export function getDiffPositionByLineCode(diffFiles, useSingleDiffStyle) { + let lines = []; + const hasInlineDiffs = diffFiles.some(file => file.highlighted_diff_lines.length > 0); + + if (!useSingleDiffStyle || hasInlineDiffs) { + // In either of these cases, we can use `highlighted_diff_lines` because + // that will include all of the parallel diff lines, too + + lines = diffFiles.reduce((acc, diffFile) => { diffFile.highlighted_diff_lines.forEach(line => { - if (line.line_code) { - acc[line.line_code] = { - base_sha: diffFile.diff_refs.base_sha, - head_sha: diffFile.diff_refs.head_sha, - start_sha: diffFile.diff_refs.start_sha, - new_path: diffFile.new_path, - old_path: diffFile.old_path, - old_line: line.old_line, - new_line: line.new_line, - line_code: line.line_code, - position_type: 'text', - }; + acc.push({ file: diffFile, line }); + }); + + return acc; + }, []); + } else { + // If we're in single diff view mode and the inline lines haven't been + // loaded yet, we need to parse the parallel lines + + lines = diffFiles.reduce((acc, diffFile) => { + diffFile.parallel_diff_lines.forEach(pair => { + // It's possible for a parallel line to have an opposite line that doesn't exist + // For example: *deleted* lines will have `null` right lines, while + // *added* lines will have `null` left lines. + // So we have to check each line before we push it onto the array so we're not + // pushing null line diffs + + if (pair.left) { + acc.push({ file: diffFile, line: pair.left }); + } + + if (pair.right) { + acc.push({ file: diffFile, line: pair.right }); } }); + + return acc; + }, []); + } + + return lines.reduce((acc, { file, line }) => { + if (line.line_code) { + acc[line.line_code] = { + base_sha: file.diff_refs.base_sha, + head_sha: file.diff_refs.head_sha, + start_sha: file.diff_refs.start_sha, + new_path: file.new_path, + old_path: file.old_path, + old_line: line.old_line, + new_line: line.new_line, + line_code: line.line_code, + position_type: 'text', + }; } return acc; @@ -462,47 +555,47 @@ export const convertExpandLines = ({ export const idleCallback = cb => requestIdleCallback(cb); -export const updateLineInFile = (selectedFile, lineCode, updateFn) => { - if (selectedFile.parallel_diff_lines) { - const targetLine = selectedFile.parallel_diff_lines.find( - line => - (line.left && line.left.line_code === lineCode) || - (line.right && line.right.line_code === lineCode), - ); - if (targetLine) { - const side = targetLine.left && targetLine.left.line_code === lineCode ? 'left' : 'right'; +function getLinesFromFileByLineCode(file, lineCode) { + const parallelLines = file.parallel_diff_lines; + const inlineLines = file.highlighted_diff_lines; + const matchesCode = line => line.line_code === lineCode; - updateFn(targetLine[side]); - } - } - if (selectedFile.highlighted_diff_lines) { - const targetInlineLine = selectedFile.highlighted_diff_lines.find( - line => line.line_code === lineCode, - ); + return [ + ...parallelLines.reduce((acc, line) => { + if (line.left) { + acc.push(line.left); + } - if (targetInlineLine) { - updateFn(targetInlineLine); - } - } + if (line.right) { + acc.push(line.right); + } + + return acc; + }, []), + ...inlineLines, + ].filter(matchesCode); +} + +export const updateLineInFile = (selectedFile, lineCode, updateFn) => { + getLinesFromFileByLineCode(selectedFile, lineCode).forEach(updateFn); }; export const allDiscussionWrappersExpanded = diff => { - const discussionsExpandedArray = []; - if (diff.parallel_diff_lines) { - diff.parallel_diff_lines.forEach(line => { - if (line.left && line.left.discussions.length) { - discussionsExpandedArray.push(line.left.discussionsExpanded); - } - if (line.right && line.right.discussions.length) { - discussionsExpandedArray.push(line.right.discussionsExpanded); - } - }); - } else if (diff.highlighted_diff_lines) { - diff.highlighted_diff_lines.forEach(line => { - if (line.discussions.length) { - discussionsExpandedArray.push(line.discussionsExpanded); - } - }); - } - return discussionsExpandedArray.every(el => el); + let discussionsExpanded = true; + const changeExpandedResult = line => { + if (line && line.discussions.length) { + discussionsExpanded = discussionsExpanded && line.discussionsExpanded; + } + }; + + diff.parallel_diff_lines.forEach(line => { + changeExpandedResult(line.left); + changeExpandedResult(line.right); + }); + + diff.highlighted_diff_lines.forEach(line => { + changeExpandedResult(line); + }); + + return discussionsExpanded; }; diff --git a/app/assets/javascripts/droplab/drop_down.js b/app/assets/javascripts/droplab/drop_down.js index ccb3d56ed8ca1ca1f5fe32f7884cd6895ba10f53..31d32fb50604718bc267aa9b6a1028e99f63b353 100644 --- a/app/assets/javascripts/droplab/drop_down.js +++ b/app/assets/javascripts/droplab/drop_down.js @@ -101,6 +101,11 @@ class DropDown { render(data) { const children = data ? data.map(this.renderChildren.bind(this)) : []; + + if (this.list.querySelector('.filter-dropdown-loading')) { + return; + } + const renderableList = this.list.querySelector('ul[data-dynamic]') || this.list; renderableList.innerHTML = children.join(''); diff --git a/app/assets/javascripts/dropzone_input.js b/app/assets/javascripts/dropzone_input.js index 62b390a46d716e28a01a63606c0ca2686cbccbaf..865908658926fa8fd479180de98efe47e5f44e1e 100644 --- a/app/assets/javascripts/dropzone_input.js +++ b/app/assets/javascripts/dropzone_input.js @@ -2,6 +2,7 @@ import $ from 'jquery'; import Dropzone from 'dropzone'; import _ from 'underscore'; import './behaviors/preview_markdown'; +import PasteMarkdownTable from './behaviors/markdown/paste_markdown_table'; import csrf from './lib/utils/csrf'; import axios from './lib/utils/axios_utils'; import { n__, __ } from '~/locale'; @@ -173,14 +174,25 @@ export default function dropzoneInput(form) { // eslint-disable-next-line consistent-return handlePaste = event => { const pasteEvent = event.originalEvent; - if (pasteEvent.clipboardData && pasteEvent.clipboardData.items) { - const image = isImage(pasteEvent); - if (image) { + const { clipboardData } = pasteEvent; + if (clipboardData && clipboardData.items) { + const converter = new PasteMarkdownTable(clipboardData); + // Apple Numbers copies a table as an image, HTML, and text, so + // we need to check for the presence of a table first. + if (converter.isTable()) { event.preventDefault(); - const filename = getFilename(pasteEvent) || 'image.png'; - const text = `{{${filename}}}`; + const text = converter.convertToTableMarkdown(); pasteText(text); - return uploadFile(image.getAsFile(), filename); + } else { + const image = isImage(pasteEvent); + + if (image) { + event.preventDefault(); + const filename = getFilename(pasteEvent) || 'image.png'; + const text = `{{${filename}}}`; + pasteText(text); + return uploadFile(image.getAsFile(), filename); + } } } }; diff --git a/app/assets/javascripts/due_date_select.js b/app/assets/javascripts/due_date_select.js index 3c650397a191241fe2b488a57656f886c0670b13..b973316b3b94b6f8271c46b1f3f078d4f5925015 100644 --- a/app/assets/javascripts/due_date_select.js +++ b/app/assets/javascripts/due_date_select.js @@ -116,11 +116,13 @@ class DueDateSelect { } updateIssueBoardIssue() { + // eslint-disable-next-line no-jquery/no-fade this.$loading.fadeIn(); this.$dropdown.trigger('loading.gl.dropdown'); this.$selectbox.hide(); this.$value.css('display', ''); const fadeOutLoader = () => { + // eslint-disable-next-line no-jquery/no-fade this.$loading.fadeOut(); }; @@ -135,6 +137,7 @@ class DueDateSelect { const hasDueDate = this.displayedDate !== __('None'); const displayedDateStyle = hasDueDate ? 'bold' : 'no-value'; + // eslint-disable-next-line no-jquery/no-fade this.$loading.removeClass('hidden').fadeIn(); if (isDropdown) { @@ -158,6 +161,7 @@ class DueDateSelect { } this.$sidebarCollapsedValue.attr('data-original-title', tooltipText); + // eslint-disable-next-line no-jquery/no-fade return this.$loading.fadeOut(); }); } diff --git a/app/assets/javascripts/environments/components/environment_item.vue b/app/assets/javascripts/environments/components/environment_item.vue index 428dfe5fcf792c900f22b2437520c802ef186359..3096ccad0aaa45a9be8eaabc81c71d163006a427 100644 --- a/app/assets/javascripts/environments/components/environment_item.vue +++ b/app/assets/javascripts/environments/components/environment_item.vue @@ -1,22 +1,23 @@ <script> /* eslint-disable @gitlab/vue-i18n/no-bare-strings */ -import { format } from 'timeago.js'; import _ from 'underscore'; import { GlTooltipDirective } from '@gitlab/ui'; -import environmentItemMixin from 'ee_else_ce/environments/mixins/environment_item_mixin'; +import { __, sprintf } from '~/locale'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import timeagoMixin from '~/vue_shared/mixins/timeago'; import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; +import CommitComponent from '~/vue_shared/components/commit.vue'; import Icon from '~/vue_shared/components/icon.vue'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; -import { __, sprintf } from '~/locale'; +import environmentItemMixin from 'ee_else_ce/environments/mixins/environment_item_mixin'; +import eventHub from '../event_hub'; import ActionsComponent from './environment_actions.vue'; import ExternalUrlComponent from './environment_external_url.vue'; -import StopComponent from './environment_stop.vue'; +import MonitoringButtonComponent from './environment_monitoring.vue'; +import PinComponent from './environment_pin.vue'; import RollbackComponent from './environment_rollback.vue'; +import StopComponent from './environment_stop.vue'; import TerminalButtonComponent from './environment_terminal_button.vue'; -import MonitoringButtonComponent from './environment_monitoring.vue'; -import CommitComponent from '../../vue_shared/components/commit.vue'; -import eventHub from '../event_hub'; -import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; /** * Environment Item Component @@ -26,21 +27,22 @@ import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; export default { components: { - CommitComponent, - Icon, ActionsComponent, + CommitComponent, ExternalUrlComponent, - StopComponent, + Icon, + MonitoringButtonComponent, + PinComponent, RollbackComponent, + StopComponent, TerminalButtonComponent, - MonitoringButtonComponent, TooltipOnTruncate, UserAvatarLink, }, directives: { GlTooltip: GlTooltipDirective, }, - mixins: [environmentItemMixin], + mixins: [environmentItemMixin, timeagoMixin], props: { canReadEnvironment: { @@ -52,7 +54,12 @@ export default { model: { type: Object, required: true, - default: () => ({}), + }, + + shouldShowAutoStopDate: { + type: Boolean, + required: false, + default: false, }, tableData: { @@ -76,6 +83,16 @@ export default { return false; }, + /** + * Checkes whether the row displayed is a folder. + * + * @returns {Boolean} + */ + + isFolder() { + return this.model.isFolder; + }, + /** * Checkes whether the environment is protected. * (`is_protected` currently only set in EE) @@ -112,24 +129,64 @@ export default { }, /** - * Verifies if the date to be shown is present. + * Verifies if the autostop date is present. + * + * @returns {Boolean} + */ + canShowAutoStopDate() { + if (!this.model.auto_stop_at) { + return false; + } + + const autoStopDate = new Date(this.model.auto_stop_at); + const now = new Date(); + + return now < autoStopDate; + }, + + /** + * Human readable deployment date. + * + * @returns {String} + */ + autoStopDate() { + if (this.canShowAutoStopDate) { + return { + formatted: this.timeFormatted(this.model.auto_stop_at), + tooltip: this.tooltipTitle(this.model.auto_stop_at), + }; + } + return { + formatted: '', + tooltip: '', + }; + }, + + /** + * Verifies if the deployment date is present. * * @returns {Boolean|Undefined} */ - canShowDate() { + canShowDeploymentDate() { return this.model && this.model.last_deployment && this.model.last_deployment.deployed_at; }, /** - * Human readable date. + * Human readable deployment date. * * @returns {String} */ deployedDate() { - if (this.canShowDate) { - return format(this.model.last_deployment.deployed_at); + if (this.canShowDeploymentDate) { + return { + formatted: this.timeFormatted(this.model.last_deployment.deployed_at), + tooltip: this.tooltipTitle(this.model.last_deployment.deployed_at), + }; } - return ''; + return { + formatted: '', + tooltip: '', + }; }, actions() { @@ -344,6 +401,15 @@ export default { return {}; }, + /** + * Checkes whether to display no deployment text. + * + * @returns {Boolean} + */ + showNoDeployments() { + return !this.hasLastDeploymentKey && !this.isFolder; + }, + /** * Verifies if the build name column should be rendered by verifing * if all the information needed is present @@ -353,7 +419,7 @@ export default { */ shouldRenderBuildName() { return ( - !this.model.isFolder && + !this.isFolder && !_.isEmpty(this.model.last_deployment) && !_.isEmpty(this.model.last_deployment.deployable) ); @@ -383,11 +449,7 @@ export default { * @return {String} */ externalURL() { - if (this.model && this.model.external_url) { - return this.model.external_url; - } - - return ''; + return this.model.external_url || ''; }, /** @@ -399,26 +461,22 @@ export default { */ shouldRenderDeploymentID() { return ( - !this.model.isFolder && + !this.isFolder && !_.isEmpty(this.model.last_deployment) && this.model.last_deployment.iid !== undefined ); }, environmentPath() { - if (this.model && this.model.environment_path) { - return this.model.environment_path; - } - - return ''; + return this.model.environment_path || ''; }, monitoringUrl() { - if (this.model && this.model.metrics_path) { - return this.model.metrics_path; - } + return this.model.metrics_path || ''; + }, - return ''; + autoStopUrl() { + return this.model.cancel_auto_stop_path || ''; }, displayEnvironmentActions() { @@ -447,7 +505,7 @@ export default { <div :class="{ 'js-child-row environment-child-row': model.isChildren, - 'folder-row': model.isFolder, + 'folder-row': isFolder, }" class="gl-responsive-table-row" role="row" @@ -457,7 +515,7 @@ export default { :class="tableData.name.spacing" role="gridcell" > - <div v-if="!model.isFolder" class="table-mobile-header" role="rowheader"> + <div v-if="!isFolder" class="table-mobile-header" role="rowheader"> {{ tableData.name.title }} </div> @@ -466,7 +524,7 @@ export default { </span> <span - v-if="!model.isFolder" + v-if="!isFolder" v-gl-tooltip :title="model.name" class="environment-name table-mobile-content" @@ -506,7 +564,7 @@ export default { {{ deploymentInternalId }} </span> - <span v-if="!model.isFolder && deploymentHasUser" class="text-break-word"> + <span v-if="!isFolder && deploymentHasUser" class="text-break-word"> by <user-avatar-link :link-href="deploymentUser.web_url" @@ -516,6 +574,10 @@ export default { class="js-deploy-user-container float-none" /> </span> + + <div v-if="showNoDeployments" class="commit-title table-mobile-content"> + {{ s__('Environments|No deployments yet') }} + </div> </div> <div @@ -536,14 +598,8 @@ export default { </a> </div> - <div - v-if="!model.isFolder" - class="table-section" - :class="tableData.commit.spacing" - role="gridcell" - > + <div v-if="!isFolder" class="table-section" :class="tableData.commit.spacing" role="gridcell"> <div role="rowheader" class="table-mobile-header">{{ tableData.commit.title }}</div> - <div v-if="hasLastDeploymentKey" class="js-commit-component table-mobile-content"> <commit-component :tag="commitTag" @@ -554,31 +610,51 @@ export default { :author="commitAuthor" /> </div> - <div v-if="!hasLastDeploymentKey" class="commit-title table-mobile-content"> - {{ s__('Environments|No deployments yet') }} - </div> + </div> + + <div v-if="!isFolder" class="table-section" :class="tableData.date.spacing" role="gridcell"> + <div role="rowheader" class="table-mobile-header">{{ tableData.date.title }}</div> + <span + v-if="canShowDeploymentDate" + v-gl-tooltip + :title="deployedDate.tooltip" + class="environment-created-date-timeago table-mobile-content flex-truncate-parent" + > + <span class="flex-truncate-child"> + {{ deployedDate.formatted }} + </span> + </span> </div> <div - v-if="!model.isFolder" + v-if="!isFolder && shouldShowAutoStopDate" class="table-section" - :class="tableData.date.spacing" + :class="tableData.autoStop.spacing" role="gridcell" > - <div role="rowheader" class="table-mobile-header">{{ tableData.date.title }}</div> - - <span v-if="canShowDate" class="environment-created-date-timeago table-mobile-content"> - {{ deployedDate }} + <div role="rowheader" class="table-mobile-header">{{ tableData.autoStop.title }}</div> + <span + v-if="canShowAutoStopDate" + v-gl-tooltip + :title="autoStopDate.tooltip" + class="table-mobile-content flex-truncate-parent" + > + <span class="flex-truncate-child js-auto-stop">{{ autoStopDate.formatted }}</span> </span> </div> <div - v-if="!model.isFolder && displayEnvironmentActions" + v-if="!isFolder && displayEnvironmentActions" class="table-section table-button-footer" :class="tableData.actions.spacing" role="gridcell" > <div class="btn-group table-action-buttons" role="group"> + <pin-component + v-if="canShowAutoStopDate && shouldShowAutoStopDate" + :auto-stop-url="autoStopUrl" + /> + <external-url-component v-if="externalURL && canReadEnvironment" :external-url="externalURL" diff --git a/app/assets/javascripts/environments/components/environment_pin.vue b/app/assets/javascripts/environments/components/environment_pin.vue new file mode 100644 index 0000000000000000000000000000000000000000..7908928a7ac67c041fde1f9e84abc79f7b44f1ba --- /dev/null +++ b/app/assets/javascripts/environments/components/environment_pin.vue @@ -0,0 +1,37 @@ +<script> +/** + * Renders a prevent auto-stop button. + * Used in environments table. + */ +import { GlButton, GlTooltipDirective } from '@gitlab/ui'; +import Icon from '~/vue_shared/components/icon.vue'; +import { __ } from '~/locale'; +import eventHub from '../event_hub'; + +export default { + components: { + Icon, + GlButton, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + autoStopUrl: { + type: String, + required: true, + }, + }, + methods: { + onPinClick() { + eventHub.$emit('cancelAutoStop', this.autoStopUrl); + }, + }, + title: __('Prevent environment from auto-stopping'), +}; +</script> +<template> + <gl-button v-gl-tooltip :title="$options.title" :aria-label="$options.title" @click="onPinClick"> + <icon name="thumbtack" /> + </gl-button> +</template> diff --git a/app/assets/javascripts/environments/components/environment_rollback.vue b/app/assets/javascripts/environments/components/environment_rollback.vue index bafbc00597ec73ccf30c1764d86ea32cc36b48e4..6279bbc83eefb684c9c39d6e224b18f2524e2aed 100644 --- a/app/assets/javascripts/environments/components/environment_rollback.vue +++ b/app/assets/javascripts/environments/components/environment_rollback.vue @@ -8,7 +8,6 @@ import { GlTooltipDirective, GlLoadingIcon, GlModalDirective, GlButton } from '@gitlab/ui'; import { s__ } from '~/locale'; import Icon from '~/vue_shared/components/icon.vue'; -import ConfirmRollbackModal from './confirm_rollback_modal.vue'; import eventHub from '../event_hub'; export default { @@ -16,7 +15,6 @@ export default { Icon, GlLoadingIcon, GlButton, - ConfirmRollbackModal, }, directives: { GlTooltip: GlTooltipDirective, diff --git a/app/assets/javascripts/environments/components/environments_table.vue b/app/assets/javascripts/environments/components/environments_table.vue index 453e7610e2113e064d383a14a29811285479dc16..30299ccc7bc56963332b58e11a0ea6d51483d7d0 100644 --- a/app/assets/javascripts/environments/components/environments_table.vue +++ b/app/assets/javascripts/environments/components/environments_table.vue @@ -6,6 +6,7 @@ import { GlLoadingIcon } from '@gitlab/ui'; import _ from 'underscore'; import environmentTableMixin from 'ee_else_ce/environments/mixins/environments_table_mixin'; import { s__ } from '~/locale'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import EnvironmentItem from './environment_item.vue'; export default { @@ -16,7 +17,7 @@ export default { CanaryDeploymentCallout: () => import('ee_component/environments/components/canary_deployment_callout.vue'), }, - mixins: [environmentTableMixin], + mixins: [environmentTableMixin, glFeatureFlagsMixin()], props: { environments: { type: Array, @@ -42,6 +43,9 @@ export default { : env, ); }, + shouldShowAutoStopDate() { + return this.glFeatures.autoStopEnvironments; + }, tableData() { return { // percent spacing for cols, should add up to 100 @@ -65,8 +69,12 @@ export default { title: s__('Environments|Updated'), spacing: 'section-10', }, + autoStop: { + title: s__('Environments|Auto stop in'), + spacing: 'section-5', + }, actions: { - spacing: 'section-30', + spacing: this.shouldShowAutoStopDate ? 'section-25' : 'section-30', }, }; }, @@ -123,6 +131,14 @@ export default { <div class="table-section" :class="tableData.date.spacing" role="columnheader"> {{ tableData.date.title }} </div> + <div + v-if="shouldShowAutoStopDate" + class="table-section" + :class="tableData.autoStop.spacing" + role="columnheader" + > + {{ tableData.autoStop.title }} + </div> </div> <template v-for="(model, i) in sortedEnvironments" :model="model"> <div @@ -130,6 +146,7 @@ export default { :key="`environment-item-${i}`" :model="model" :can-read-environment="canReadEnvironment" + :should-show-auto-stop-date="shouldShowAutoStopDate" :table-data="tableData" /> diff --git a/app/assets/javascripts/environments/components/stop_environment_modal.vue b/app/assets/javascripts/environments/components/stop_environment_modal.vue index 1ea4e30a7c1f72fa0ff44b89787a2d3ff68cbd17..43ebd7b2824eb9cef0fb883318a4b40b9a1d4ddf 100644 --- a/app/assets/javascripts/environments/components/stop_environment_modal.vue +++ b/app/assets/javascripts/environments/components/stop_environment_modal.vue @@ -3,7 +3,6 @@ import { GlTooltipDirective } from '@gitlab/ui'; import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue'; import { s__, sprintf } from '~/locale'; -import LoadingButton from '~/vue_shared/components/loading_button.vue'; import eventHub from '../event_hub'; export default { @@ -12,7 +11,6 @@ export default { components: { GlModal: DeprecatedModal2, - LoadingButton, }, directives: { diff --git a/app/assets/javascripts/environments/mixins/environments_mixin.js b/app/assets/javascripts/environments/mixins/environments_mixin.js index 31347d95a2502b6f1ccd385dc503dc08e99afcc8..34374e306a44379aa86afe080ab74d730e08f01f 100644 --- a/app/assets/javascripts/environments/mixins/environments_mixin.js +++ b/app/assets/javascripts/environments/mixins/environments_mixin.js @@ -90,16 +90,19 @@ export default { Flash(s__('Environments|An error occurred while fetching the environments.')); }, - postAction({ endpoint, errorMessage }) { + postAction({ + endpoint, + errorMessage = s__('Environments|An error occurred while making the request.'), + }) { if (!this.isMakingRequest) { this.isLoading = true; this.service .postAction(endpoint) .then(() => this.fetchEnvironments()) - .catch(() => { + .catch(err => { this.isLoading = false; - Flash(errorMessage || s__('Environments|An error occurred while making the request.')); + Flash(_.isFunction(errorMessage) ? errorMessage(err.response.data) : errorMessage); }); } }, @@ -138,6 +141,13 @@ export default { ); this.postAction({ endpoint: retryUrl, errorMessage }); }, + + cancelAutoStop(autoStopPath) { + const errorMessage = ({ message }) => + message || + s__('Environments|An error occurred while canceling the auto stop, please try again'); + this.postAction({ endpoint: autoStopPath, errorMessage }); + }, }, computed: { @@ -199,6 +209,8 @@ export default { eventHub.$on('requestRollbackEnvironment', this.updateRollbackModal); eventHub.$on('rollbackEnvironment', this.rollbackEnvironment); + + eventHub.$on('cancelAutoStop', this.cancelAutoStop); }, beforeDestroy() { @@ -208,5 +220,7 @@ export default { eventHub.$off('requestRollbackEnvironment', this.updateRollbackModal); eventHub.$off('rollbackEnvironment', this.rollbackEnvironment); + + eventHub.$off('cancelAutoStop', this.cancelAutoStop); }, }; diff --git a/app/assets/javascripts/error_tracking/components/error_details.vue b/app/assets/javascripts/error_tracking/components/error_details.vue index 14b2e59009ad599661bad5049d8bce79fc4a8eea..819d501cba6be1c7435ba83c5a490cfd5c1b5444 100644 --- a/app/assets/javascripts/error_tracking/components/error_details.vue +++ b/app/assets/javascripts/error_tracking/components/error_details.vue @@ -1,7 +1,8 @@ <script> import { mapActions, mapGetters, mapState } from 'vuex'; import dateFormat from 'dateformat'; -import { GlFormInput, GlLink, GlLoadingIcon } from '@gitlab/ui'; +import createFlash from '~/flash'; +import { GlButton, GlFormInput, GlLink, GlLoadingIcon, GlBadge } from '@gitlab/ui'; import { __, sprintf, n__ } from '~/locale'; import LoadingButton from '~/vue_shared/components/loading_button.vue'; import Icon from '~/vue_shared/components/icon.vue'; @@ -11,21 +12,41 @@ import TrackEventDirective from '~/vue_shared/directives/track_event'; import timeagoMixin from '~/vue_shared/mixins/timeago'; import { trackClickErrorLinkToSentryOptions } from '../utils'; +import query from '../queries/details.query.graphql'; + export default { components: { LoadingButton, + GlButton, GlFormInput, GlLink, GlLoadingIcon, TooltipOnTruncate, Icon, Stacktrace, + GlBadge, }, directives: { TrackEvent: TrackEventDirective, }, mixins: [timeagoMixin], props: { + listPath: { + type: String, + required: true, + }, + issueUpdatePath: { + type: String, + required: true, + }, + issueId: { + type: String, + required: true, + }, + projectPath: { + type: String, + required: true, + }, issueDetailsPath: { type: String, required: true, @@ -43,38 +64,67 @@ export default { required: true, }, }, + apollo: { + GQLerror: { + query, + variables() { + return { + fullPath: this.projectPath, + errorId: `gid://gitlab/Gitlab::ErrorTracking::DetailedError/${this.issueId}`, + }; + }, + pollInterval: 2000, + update: data => data.project.sentryDetailedError, + error: () => createFlash(__('Failed to load error details from Sentry.')), + result(res) { + if (res.data.project?.sentryDetailedError) { + this.$apollo.queries.GQLerror.stopPolling(); + } + }, + }, + }, data() { return { + GQLerror: null, issueCreationInProgress: false, }; }, computed: { - ...mapState('details', ['error', 'loading', 'loadingStacktrace', 'stacktraceData']), + ...mapState('details', [ + 'error', + 'loading', + 'loadingStacktrace', + 'stacktraceData', + 'updatingResolveStatus', + 'updatingIgnoreStatus', + ]), ...mapGetters('details', ['stacktrace']), reported() { return sprintf( __('Reported %{timeAgo} by %{reportedBy}'), { - reportedBy: `<strong>${this.error.culprit}</strong>`, + reportedBy: `<strong>${this.GQLerror.culprit}</strong>`, timeAgo: this.timeFormatted(this.stacktraceData.date_received), }, false, ); }, firstReleaseLink() { - return `${this.error.external_base_url}/releases/${this.error.first_release_short_version}`; + return `${this.error.external_base_url}/releases/${this.GQLerror.firstReleaseShortVersion}`; }, lastReleaseLink() { - return `${this.error.external_base_url}releases/${this.error.last_release_short_version}`; + return `${this.error.external_base_url}releases/${this.GQLerror.lastReleaseShortVersion}`; }, showDetails() { - return Boolean(!this.loading && this.error && this.error.id); + return Boolean( + !this.loading && !this.$apollo.queries.GQLerror.loading && this.error && this.GQLerror, + ); }, showStacktrace() { return Boolean(!this.loadingStacktrace && this.stacktrace && this.stacktrace.length); }, issueTitle() { - return this.error.title; + return this.GQLerror.title; }, issueDescription() { return sprintf( @@ -83,29 +133,35 @@ export default { ), { description: '# Error Details:\n', - errorUrl: `${this.error.external_url}\n`, - firstSeen: `\n${this.error.first_seen}\n`, - lastSeen: `${this.error.last_seen}\n`, - countLabel: n__('- Event', '- Events', this.error.count), - count: `${this.error.count}\n`, - userCountLabel: n__('- User', '- Users', this.error.user_count), - userCount: `${this.error.user_count}\n`, + errorUrl: `${this.GQLerror.externalUrl}\n`, + firstSeen: `\n${this.GQLerror.firstSeen}\n`, + lastSeen: `${this.GQLerror.lastSeen}\n`, + countLabel: n__('- Event', '- Events', this.GQLerror.count), + count: `${this.GQLerror.count}\n`, + userCountLabel: n__('- User', '- Users', this.GQLerror.userCount), + userCount: `${this.GQLerror.userCount}\n`, }, false, ); }, + errorLevel() { + return sprintf(__('level: %{level}'), { level: this.error.tags.level }); + }, }, mounted() { this.startPollingDetails(this.issueDetailsPath); this.startPollingStacktrace(this.issueStackTracePath); }, methods: { - ...mapActions('details', ['startPollingDetails', 'startPollingStacktrace']), + ...mapActions('details', ['startPollingDetails', 'startPollingStacktrace', 'updateStatus']), trackClickErrorLinkToSentryOptions, createIssue() { this.issueCreationInProgress = true; this.$refs.sentryIssueForm.submit(); }, + updateIssueStatus(status) { + this.updateStatus({ endpoint: this.issueUpdatePath, redirectUrl: this.listPath, status }); + }, formatDate(date) { return `${this.timeFormatted(date)} (${dateFormat(date, 'UTC:yyyy-mm-dd h:MM:ssTT Z')})`; }, @@ -115,75 +171,118 @@ export default { <template> <div> - <div v-if="loading" class="py-3"> + <div v-if="$apollo.queries.GQLerror.loading || loading" class="py-3"> <gl-loading-icon :size="3" /> </div> <div v-else-if="showDetails" class="error-details"> <div class="top-area align-items-center justify-content-between py-3"> <span v-if="!loadingStacktrace && stacktrace" v-html="reported"></span> - <form ref="sentryIssueForm" :action="projectIssuesPath" method="POST"> - <gl-form-input class="hidden" name="issue[title]" :value="issueTitle" /> - <input name="issue[description]" :value="issueDescription" type="hidden" /> - <gl-form-input - :value="error.id" - class="hidden" - name="issue[sentry_issue_attributes][sentry_issue_identifier]" + <div class="d-inline-flex"> + <loading-button + :label="__('Ignore')" + :loading="updatingIgnoreStatus" + @click="updateIssueStatus('ignored')" /> - <gl-form-input :value="csrfToken" class="hidden" name="authenticity_token" /> <loading-button - v-if="!error.gitlab_issue" - class="btn-success" - :label="__('Create issue')" - :loading="issueCreationInProgress" - data-qa-selector="create_issue_button" - @click="createIssue" + class="btn-outline-info ml-2" + :label="__('Resolve')" + :loading="updatingResolveStatus" + @click="updateIssueStatus('resolved')" /> - </form> + <gl-button + v-if="error.gitlab_issue" + class="ml-2" + data-qa-selector="view_issue_button" + :href="error.gitlab_issue" + variant="success" + > + {{ __('View issue') }} + </gl-button> + <form + ref="sentryIssueForm" + :action="projectIssuesPath" + method="POST" + class="d-inline-block ml-2" + > + <gl-form-input class="hidden" name="issue[title]" :value="issueTitle" /> + <input name="issue[description]" :value="issueDescription" type="hidden" /> + <gl-form-input + :value="GQLerror.sentryId" + class="hidden" + name="issue[sentry_issue_attributes][sentry_issue_identifier]" + /> + <gl-form-input :value="csrfToken" class="hidden" name="authenticity_token" /> + <loading-button + v-if="!error.gitlab_issue" + class="btn-success" + :label="__('Create issue')" + :loading="issueCreationInProgress" + data-qa-selector="create_issue_button" + @click="createIssue" + /> + </form> + </div> </div> <div> - <tooltip-on-truncate :title="error.title" truncate-target="child" placement="top"> - <h2 class="text-truncate">{{ error.title }}</h2> + <tooltip-on-truncate :title="GQLerror.title" truncate-target="child" placement="top"> + <h2 class="text-truncate">{{ GQLerror.title }}</h2> </tooltip-on-truncate> - <h3>{{ __('Error details') }}</h3> + <template v-if="error.tags"> + <gl-badge v-if="error.tags.level" variant="danger" class="rounded-pill mr-2" + >{{ errorLevel }} + </gl-badge> + <gl-badge v-if="error.tags.logger" variant="light" class="rounded-pill" + >{{ error.tags.logger }} + </gl-badge> + </template> <ul> + <li v-if="GQLerror.gitlabCommit"> + <strong class="bold">{{ __('GitLab commit') }}:</strong> + <gl-link :href="GQLerror.gitlabCommitPath"> + <span>{{ GQLerror.gitlabCommit.substr(0, 10) }}</span> + </gl-link> + </li> <li v-if="error.gitlab_issue"> - <span class="bold">{{ __('GitLab Issue') }}:</span> + <strong class="bold">{{ __('GitLab Issue') }}:</strong> <gl-link :href="error.gitlab_issue"> <span>{{ error.gitlab_issue }}</span> </gl-link> </li> <li> - <span class="bold">{{ __('Sentry event') }}:</span> + <strong class="bold">{{ __('Sentry event') }}:</strong> <gl-link - v-track-event="trackClickErrorLinkToSentryOptions(error.external_url)" - :href="error.external_url" + v-track-event="trackClickErrorLinkToSentryOptions(GQLerror.externalUrl)" + class="d-inline-flex align-items-center" + :href="GQLerror.externalUrl" target="_blank" > - <span class="text-truncate">{{ error.external_url }}</span> + <span class="text-truncate">{{ GQLerror.externalUrl }}</span> <icon name="external-link" class="ml-1 flex-shrink-0" /> </gl-link> </li> - <li v-if="error.first_release_short_version"> - <span class="bold">{{ __('First seen') }}:</span> - {{ formatDate(error.first_seen) }} + <li v-if="GQLerror.firstReleaseShortVersion"> + <strong class="bold">{{ __('First seen') }}:</strong> + {{ formatDate(GQLerror.firstSeen) }} <gl-link :href="firstReleaseLink" target="_blank"> - <span>{{ __('Release') }}: {{ error.first_release_short_version }}</span> + <span> + {{ __('Release') }}: {{ GQLerror.firstReleaseShortVersion.substr(0, 10) }} + </span> </gl-link> </li> - <li v-if="error.last_release_short_version"> - <span class="bold">{{ __('Last seen') }}:</span> - {{ formatDate(error.last_seen) }} + <li v-if="GQLerror.lastReleaseShortVersion"> + <strong class="bold">{{ __('Last seen') }}:</strong> + {{ formatDate(GQLerror.lastSeen) }} <gl-link :href="lastReleaseLink" target="_blank"> - <span>{{ __('Release') }}: {{ error.last_release_short_version }}</span> + <span>{{ __('Release') }}: {{ GQLerror.lastReleaseShortVersion.substr(0, 10) }}</span> </gl-link> </li> <li> - <span class="bold">{{ __('Events') }}:</span> - <span>{{ error.count }}</span> + <strong class="bold">{{ __('Events') }}:</strong> + <span>{{ GQLerror.count }}</span> </li> <li> - <span class="bold">{{ __('Users') }}:</span> - <span>{{ error.user_count }}</span> + <strong class="bold">{{ __('Users') }}:</strong> + <span>{{ GQLerror.userCount }}</span> </li> </ul> diff --git a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue index 8e2128ac713e34bb985e38c27998ea83a6edea38..3280ff4812922c97f94e3a4a5eac13f6157e8a2c 100644 --- a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue +++ b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue @@ -25,10 +25,47 @@ export default { PREV_PAGE: 1, NEXT_PAGE: 2, fields: [ - { key: 'error', label: __('Open errors'), thClass: 'w-70p' }, - { key: 'events', label: __('Events') }, - { key: 'users', label: __('Users') }, - { key: 'lastSeen', label: __('Last seen'), thClass: 'w-15p' }, + { + key: 'error', + label: __('Error'), + thClass: 'w-60p', + tdClass: 'table-col d-flex d-sm-table-cell px-3', + }, + { + key: 'events', + label: __('Events'), + thClass: 'text-right', + tdClass: 'table-col d-flex d-sm-table-cell', + }, + { + key: 'users', + label: __('Users'), + thClass: 'text-right', + tdClass: 'table-col d-flex d-sm-table-cell', + }, + { + key: 'lastSeen', + label: __('Last seen'), + thClass: '', + tdClass: 'table-col d-flex d-sm-table-cell', + }, + { + key: 'ignore', + label: '', + thClass: 'w-3rem', + tdClass: 'table-col d-flex pl-0 d-sm-table-cell', + }, + { + key: 'resolved', + label: '', + thClass: 'w-3rem', + tdClass: 'table-col d-flex pl-0 d-sm-table-cell', + }, + { + key: 'details', + tdClass: 'table-col d-sm-none d-flex align-items-center', + thClass: 'invisible w-0', + }, ], sortFields: { last_seen: __('Last Seen'), @@ -74,6 +111,14 @@ export default { type: Boolean, required: true, }, + projectPath: { + type: String, + required: true, + }, + listPath: { + type: String, + required: true, + }, }, hasLocalStorage: AccessorUtils.isLocalStorageAccessSafe(), data() { @@ -90,6 +135,7 @@ export default { 'sortField', 'recentSearches', 'pagination', + 'cursor', ]), paginationRequired() { return !_.isEmpty(this.pagination); @@ -119,6 +165,8 @@ export default { 'clearRecentSearches', 'loadRecentSearches', 'setIndexPath', + 'fetchPaginatedResults', + 'updateStatus', ]), setSearchText(text) { this.errorSearchQuery = text; @@ -129,10 +177,10 @@ export default { }, goToNextPage() { this.pageValue = this.$options.NEXT_PAGE; - this.startPolling(`${this.indexPath}?cursor=${this.pagination.next.cursor}`); + this.fetchPaginatedResults(this.pagination.next.cursor); }, goToPrevPage() { - this.startPolling(`${this.indexPath}?cursor=${this.pagination.previous.cursor}`); + this.fetchPaginatedResults(this.pagination.previous.cursor); }, goToPage(page) { window.scrollTo(0, 0); @@ -141,6 +189,16 @@ export default { isCurrentSortField(field) { return field === this.sortField; }, + getIssueUpdatePath(errorId) { + return `/${this.projectPath}/-/error_tracking/${errorId}.json`; + }, + updateIssueStatus(errorId, status) { + this.updateStatus({ + endpoint: this.getIssueUpdatePath(errorId), + redirectUrl: this.listPath, + status, + }); + }, }, }; </script> @@ -148,62 +206,62 @@ export default { <template> <div class="error-list"> <div v-if="errorTrackingEnabled"> - <div - class="d-flex flex-row justify-content-around align-items-center bg-secondary border mt-2" - > - <div class="filtered-search-box flex-grow-1 my-3 ml-3 mr-2"> - <gl-dropdown - :text="__('Recent searches')" - class="filtered-search-history-dropdown-wrapper d-none d-md-block" - toggle-class="filtered-search-history-dropdown-toggle-button" - :disabled="loading" - > - <div v-if="!$options.hasLocalStorage" class="px-3"> - {{ __('This feature requires local storage to be enabled') }} - </div> - <template v-else-if="recentSearches.length > 0"> - <gl-dropdown-item - v-for="searchQuery in recentSearches" - :key="searchQuery" - @click="setSearchText(searchQuery)" - >{{ searchQuery }}</gl-dropdown-item - > - <gl-dropdown-divider /> - <gl-dropdown-item ref="clearRecentSearches" @click="clearRecentSearches">{{ - __('Clear recent searches') - }}</gl-dropdown-item> - </template> - <div v-else class="px-3">{{ __("You don't have any recent searches") }}</div> - </gl-dropdown> - <div class="filtered-search-input-container flex-fill"> - <gl-form-input - v-model="errorSearchQuery" - class="pl-2 filtered-search" + <div class="row flex-column flex-sm-row align-items-sm-center row-top m-0 mt-sm-2 p-0 p-sm-3"> + <div class="search-box flex-fill mr-sm-2 my-3 m-sm-0 p-3 p-sm-0"> + <div class="filtered-search-box mb-0"> + <gl-dropdown + :text="__('Recent searches')" + class="filtered-search-history-dropdown-wrapper" + toggle-class="filtered-search-history-dropdown-toggle-button" :disabled="loading" - :placeholder="__('Search or filter results…')" - autofocus - @keyup.enter.native="searchByQuery(errorSearchQuery)" - /> - </div> - <div class="gl-search-box-by-type-right-icons"> - <gl-button - v-if="errorSearchQuery.length > 0" - v-gl-tooltip.hover - :title="__('Clear')" - class="clear-search text-secondary" - name="clear" - @click="errorSearchQuery = ''" > - <gl-icon name="close" :size="12" /> - </gl-button> + <div v-if="!$options.hasLocalStorage" class="px-3"> + {{ __('This feature requires local storage to be enabled') }} + </div> + <template v-else-if="recentSearches.length > 0"> + <gl-dropdown-item + v-for="searchQuery in recentSearches" + :key="searchQuery" + @click="setSearchText(searchQuery)" + >{{ searchQuery }} + </gl-dropdown-item> + <gl-dropdown-divider /> + <gl-dropdown-item ref="clearRecentSearches" @click="clearRecentSearches" + >{{ __('Clear recent searches') }} + </gl-dropdown-item> + </template> + <div v-else class="px-3">{{ __("You don't have any recent searches") }}</div> + </gl-dropdown> + <div class="filtered-search-input-container flex-fill"> + <gl-form-input + v-model="errorSearchQuery" + class="pl-2 filtered-search" + :disabled="loading" + :placeholder="__('Search or filter results…')" + autofocus + @keyup.enter.native="searchByQuery(errorSearchQuery)" + /> + </div> + <div class="gl-search-box-by-type-right-icons"> + <gl-button + v-if="errorSearchQuery.length > 0" + v-gl-tooltip.hover + :title="__('Clear')" + class="clear-search text-secondary" + name="clear" + @click="errorSearchQuery = ''" + > + <gl-icon name="close" :size="12" /> + </gl-button> + </div> </div> </div> <gl-dropdown + class="sort-control" :text="$options.sortFields[sortField]" left :disabled="loading" - class="mr-3" menu-class="sort-dropdown" > <gl-dropdown-item @@ -227,62 +285,97 @@ export default { <gl-loading-icon size="md" /> </div> - <gl-table - v-else - class="mt-3" - :items="errors" - :fields="$options.fields" - :show-empty="true" - fixed - stacked="sm" - > - <template slot="HEAD_events" slot-scope="data"> - <div class="text-md-right">{{ data.label }}</div> - </template> - <template slot="HEAD_users" slot-scope="data"> - <div class="text-md-right">{{ data.label }}</div> - </template> - <template slot="error" slot-scope="errors"> - <div class="d-flex flex-column"> - <gl-link class="d-flex text-dark" :href="getDetailsLink(errors.item.id)"> - <strong class="text-truncate">{{ errors.item.title.trim() }}</strong> - </gl-link> - <span class="text-secondary text-truncate"> - {{ errors.item.culprit }} - </span> - </div> - </template> - <template slot="events" slot-scope="errors"> - <div class="text-md-right">{{ errors.item.count }}</div> - </template> + <template v-else> + <h4 class="d-block d-sm-none my-3">{{ __('Open errors') }}</h4> - <template slot="users" slot-scope="errors"> - <div class="text-md-right">{{ errors.item.userCount }}</div> - </template> + <gl-table + class="mt-3" + :items="errors" + :fields="$options.fields" + :show-empty="true" + fixed + stacked="sm" + tbody-tr-class="table-row mb-4" + > + <template v-slot:head(error)> + <div class="d-none d-sm-block">{{ __('Open errors') }}</div> + </template> + <template v-slot:head(events)="data"> + <div class="text-sm-right">{{ data.label }}</div> + </template> + <template v-slot:head(users)="data"> + <div class="text-sm-right">{{ data.label }}</div> + </template> - <template slot="lastSeen" slot-scope="errors"> - <div class="d-flex align-items-center"> - <time-ago :time="errors.item.lastSeen" class="text-secondary" /> - </div> - </template> - <template slot="empty"> - <div ref="empty"> + <template v-slot:error="errors"> + <div class="d-flex flex-column"> + <gl-link class="d-flex mw-100 text-dark" :href="getDetailsLink(errors.item.id)"> + <strong class="text-truncate">{{ errors.item.title.trim() }}</strong> + </gl-link> + <span class="text-secondary text-truncate mw-100"> + {{ errors.item.culprit }} + </span> + </div> + </template> + <template v-slot:events="errors"> + <div class="text-right">{{ errors.item.count }}</div> + </template> + + <template v-slot:users="errors"> + <div class="text-right">{{ errors.item.userCount }}</div> + </template> + + <template v-slot:lastSeen="errors"> + <div class="text-md-left text-right"> + <time-ago :time="errors.item.lastSeen" class="text-secondary" /> + </div> + </template> + <template v-slot:ignore="errors"> + <gl-button + ref="ignoreError" + v-gl-tooltip.hover + :title="__('Ignore')" + @click="updateIssueStatus(errors.item.id, 'ignored')" + > + <gl-icon name="eye-slash" :size="12" /> + </gl-button> + </template> + <template v-slot:resolved="errors"> + <gl-button + ref="resolveError" + v-gl-tooltip + :title="__('Resolve')" + @click="updateIssueStatus(errors.item.id, 'resolved')" + > + <gl-icon name="check-circle" :size="12" /> + </gl-button> + </template> + <template v-slot:details="errors"> + <gl-button + :href="getDetailsLink(errors.item.id)" + variant="outline-info" + class="d-block" + > + {{ __('More details') }} + </gl-button> + </template> + <template v-slot:empty> {{ __('No errors to display.') }} <gl-link class="js-try-again" @click="restartPolling"> {{ __('Check again') }} </gl-link> - </div> - </template> - </gl-table> - <gl-pagination - v-show="!loading" - v-if="paginationRequired" - :prev-page="$options.PREV_PAGE" - :next-page="$options.NEXT_PAGE" - :value="pageValue" - align="center" - @input="goToPage" - /> + </template> + </gl-table> + <gl-pagination + v-show="!loading" + v-if="paginationRequired" + :prev-page="$options.PREV_PAGE" + :next-page="$options.NEXT_PAGE" + :value="pageValue" + align="center" + @input="goToPage" + /> + </template> </div> <div v-else-if="userCanEnableErrorTracking"> <gl-empty-state diff --git a/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue b/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue index 62fd379aa4c85511b7d8eb144c4a6f9c421b185b..4e63e16726069a39e02a4cce4f3dab92a892b69b 100644 --- a/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue +++ b/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue @@ -1,4 +1,5 @@ <script> +import _ from 'underscore'; import { GlTooltip } from '@gitlab/ui'; import { __, sprintf } from '~/locale'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; @@ -56,17 +57,36 @@ export default { collapseIcon() { return this.isExpanded ? 'chevron-down' : 'chevron-right'; }, - noCodeFn() { - return this.errorFn ? sprintf(__('in %{errorFn} '), { errorFn: this.errorFn }) : ''; + errorFnText() { + return this.errorFn + ? sprintf( + __(`%{spanStart}in%{spanEnd} %{errorFn}`), + { + errorFn: `<strong>${_.escape(this.errorFn)}</strong>`, + spanStart: `<span class="text-tertiary">`, + spanEnd: `</span>`, + }, + false, + ) + : ''; }, - noCodeLine() { + errorPositionText() { return this.errorLine - ? sprintf(__('at line %{errorLine}%{errorColumn}'), { - errorLine: this.errorLine, - errorColumn: this.errorColumn ? `:${this.errorColumn}` : '', - }) + ? sprintf( + __(`%{spanStart}at line%{spanEnd} %{errorLine}%{errorColumn}`), + { + errorLine: `<strong>${this.errorLine}</strong>`, + errorColumn: this.errorColumn ? `:<strong>${this.errorColumn}</strong>` : ``, + spanStart: `<span class="text-tertiary">`, + spanEnd: `</span>`, + }, + false, + ) : ''; }, + errorInfo() { + return `${this.errorFnText} ${this.errorPositionText}`; + }, }, methods: { isHighlighted(lineNum) { @@ -102,8 +122,7 @@ export default { <strong v-gl-tooltip :title="filePath" - class="file-title-name d-inline-block overflow-hidden text-truncate" - :class="{ 'limited-width': !hasCode }" + class="file-title-name d-inline-block overflow-hidden text-truncate limited-width" data-container="body" > {{ filePath }} @@ -113,7 +132,7 @@ export default { :text="filePath" css-class="btn-default btn-transparent btn-clipboard position-static" /> - <span v-if="!hasCode" class="text-tertiary">{{ noCodeFn }}{{ noCodeLine }}</span> + <span v-html="errorInfo"></span> </div> </div> diff --git a/app/assets/javascripts/error_tracking/details.js b/app/assets/javascripts/error_tracking/details.js index 872cb8868a21f4efa1df496cdcb789e00722835d..c18298dec4fdd6b3b742f6eb3e3272b6948d8b17 100644 --- a/app/assets/javascripts/error_tracking/details.js +++ b/app/assets/javascripts/error_tracking/details.js @@ -1,22 +1,43 @@ import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; import store from './store'; import ErrorDetails from './components/error_details.vue'; import csrf from '~/lib/utils/csrf'; +Vue.use(VueApollo); + export default () => { + const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), + }); + // eslint-disable-next-line no-new new Vue({ el: '#js-error_details', + apolloProvider, components: { ErrorDetails, }, store, render(createElement) { const domEl = document.querySelector(this.$options.el); - const { issueDetailsPath, issueStackTracePath, projectIssuesPath } = domEl.dataset; + const { + issueId, + projectPath, + listPath, + issueUpdatePath, + issueDetailsPath, + issueStackTracePath, + projectIssuesPath, + } = domEl.dataset; return createElement('error-details', { props: { + issueId, + projectPath, + listPath, + issueUpdatePath, issueDetailsPath, issueStackTracePath, projectIssuesPath, diff --git a/app/assets/javascripts/error_tracking/list.js b/app/assets/javascripts/error_tracking/list.js index 073e2c8f1c74f136baf7b90a636b12d9d763458f..8f3700249dab6b84cb9528462e9983cb5367afb0 100644 --- a/app/assets/javascripts/error_tracking/list.js +++ b/app/assets/javascripts/error_tracking/list.js @@ -13,7 +13,13 @@ export default () => { store, render(createElement) { const domEl = document.querySelector(this.$options.el); - const { indexPath, enableErrorTrackingLink, illustrationPath } = domEl.dataset; + const { + indexPath, + enableErrorTrackingLink, + illustrationPath, + projectPath, + listPath, + } = domEl.dataset; let { errorTrackingEnabled, userCanEnableErrorTracking } = domEl.dataset; errorTrackingEnabled = parseBoolean(errorTrackingEnabled); @@ -26,6 +32,8 @@ export default () => { errorTrackingEnabled, illustrationPath, userCanEnableErrorTracking, + projectPath, + listPath, }, }); }, diff --git a/app/assets/javascripts/error_tracking/queries/details.query.graphql b/app/assets/javascripts/error_tracking/queries/details.query.graphql new file mode 100644 index 0000000000000000000000000000000000000000..625ce3030d9a148fabfc54d458e127a8eb865ab3 --- /dev/null +++ b/app/assets/javascripts/error_tracking/queries/details.query.graphql @@ -0,0 +1,20 @@ +query errorDetails($fullPath: ID!, $errorId: ID!) { + project(fullPath: $fullPath) { + sentryDetailedError(id: $errorId) { + id + sentryId + title + userCount + count + firstSeen + lastSeen + message + culprit + externalUrl + firstReleaseShortVersion + lastReleaseShortVersion + gitlabCommit + gitlabCommitPath + } + } +} diff --git a/app/assets/javascripts/error_tracking/services/index.js b/app/assets/javascripts/error_tracking/services/index.js index 3b3f8311d67bd05ec70c72a5d91b4f2fc92adf92..3fb317c17f5ec5472574590ad9470eafb89df3df 100644 --- a/app/assets/javascripts/error_tracking/services/index.js +++ b/app/assets/javascripts/error_tracking/services/index.js @@ -4,4 +4,7 @@ export default { getSentryData({ endpoint, params }) { return axios.get(endpoint, { params }); }, + updateErrorStatus(endpoint, status) { + return axios.put(endpoint, { status }); + }, }; diff --git a/app/assets/javascripts/error_tracking/store/actions.js b/app/assets/javascripts/error_tracking/store/actions.js new file mode 100644 index 0000000000000000000000000000000000000000..bb8b039b5df3e29f25feb71c6f07ae6aa35443db --- /dev/null +++ b/app/assets/javascripts/error_tracking/store/actions.js @@ -0,0 +1,19 @@ +import service from './../services'; +import * as types from './mutation_types'; +import createFlash from '~/flash'; +import { visitUrl } from '~/lib/utils/url_utility'; +import { __ } from '~/locale'; + +export function updateStatus({ commit }, { endpoint, redirectUrl, status }) { + const type = + status === 'resolved' ? types.SET_UPDATING_RESOLVE_STATUS : types.SET_UPDATING_IGNORE_STATUS; + commit(type, true); + + return service + .updateErrorStatus(endpoint, status) + .then(() => visitUrl(redirectUrl)) + .catch(() => createFlash(__('Failed to update issue status'))) + .finally(() => commit(type, false)); +} + +export default () => {}; diff --git a/app/assets/javascripts/error_tracking/store/details/state.js b/app/assets/javascripts/error_tracking/store/details/state.js index 95fb0ba055833465feaf15e2dc557b514261ed23..52b0297607de265fae7e50bfa1af318642a6c64b 100644 --- a/app/assets/javascripts/error_tracking/store/details/state.js +++ b/app/assets/javascripts/error_tracking/store/details/state.js @@ -3,4 +3,6 @@ export default () => ({ stacktraceData: {}, loading: true, loadingStacktrace: true, + updatingResolveStatus: false, + updatingIgnoreStatus: false, }); diff --git a/app/assets/javascripts/error_tracking/store/index.js b/app/assets/javascripts/error_tracking/store/index.js index ad05eecef6c75cd3fb025dab926fd68c85187fd3..d9206bc8d7c52090f3004d7a5f6cbf5d84552a3d 100644 --- a/app/assets/javascripts/error_tracking/store/index.js +++ b/app/assets/javascripts/error_tracking/store/index.js @@ -1,6 +1,9 @@ import Vue from 'vue'; import Vuex from 'vuex'; +import * as actions from './actions'; +import mutations from './mutations'; + import * as listActions from './list/actions'; import listMutations from './list/mutations'; import listState from './list/state'; @@ -18,14 +21,14 @@ export const createStore = () => list: { namespaced: true, state: listState(), - actions: listActions, - mutations: listMutations, + actions: { ...actions, ...listActions }, + mutations: { ...mutations, ...listMutations }, }, details: { namespaced: true, state: detailsState(), - actions: detailsActions, - mutations: detailsMutations, + actions: { ...actions, ...detailsActions }, + mutations: { ...mutations, ...detailsMutations }, getters: detailsGetters, }, }, diff --git a/app/assets/javascripts/error_tracking/store/list/actions.js b/app/assets/javascripts/error_tracking/store/list/actions.js index c9e882c4ed243eb6276b697e0102754b5770e3a2..d96ac7f524ef44f81be031ec25fe74f5131800d6 100644 --- a/app/assets/javascripts/error_tracking/store/list/actions.js +++ b/app/assets/javascripts/error_tracking/store/list/actions.js @@ -17,12 +17,14 @@ export function startPolling({ state, commit, dispatch }) { params: { search_term: state.searchQuery, sort: state.sortField, + cursor: state.cursor, }, }, successCallback: ({ data }) => { if (!data) { return; } + commit(types.SET_PAGINATION, data.pagination); commit(types.SET_ERRORS, data.errors); commit(types.SET_LOADING, false); @@ -74,6 +76,7 @@ export function clearRecentSearches({ commit }) { export const searchByQuery = ({ commit, dispatch }, query) => { const searchQuery = query.trim(); + commit(types.SET_CURSOR, null); commit(types.SET_SEARCH_QUERY, searchQuery); commit(types.ADD_RECENT_SEARCH, searchQuery); dispatch('stopPolling'); @@ -81,6 +84,7 @@ export const searchByQuery = ({ commit, dispatch }, query) => { }; export const sortByField = ({ commit, dispatch }, field) => { + commit(types.SET_CURSOR, null); commit(types.SET_SORT_FIELD, field); dispatch('stopPolling'); dispatch('startPolling'); @@ -90,4 +94,10 @@ export const setEndpoint = ({ commit }, endpoint) => { commit(types.SET_ENDPOINT, endpoint); }; +export const fetchPaginatedResults = ({ commit, dispatch }, cursor) => { + commit(types.SET_CURSOR, cursor); + dispatch('stopPolling'); + dispatch('startPolling'); +}; + export default () => {}; diff --git a/app/assets/javascripts/error_tracking/store/list/mutation_types.js b/app/assets/javascripts/error_tracking/store/list/mutation_types.js index 301984a1ee05c23aafe71fd75ad4f3cf6caf230d..c3468b7eabd68a8eef7947c3b96f59dedbf9c73c 100644 --- a/app/assets/javascripts/error_tracking/store/list/mutation_types.js +++ b/app/assets/javascripts/error_tracking/store/list/mutation_types.js @@ -8,3 +8,4 @@ export const SET_PAGINATION = 'SET_PAGINATION'; export const SET_ENDPOINT = 'SET_ENDPOINT'; export const SET_SORT_FIELD = 'SET_SORT_FIELD'; export const SET_SEARCH_QUERY = 'SET_SEARCH_QUERY'; +export const SET_CURSOR = 'SET_CURSOR'; diff --git a/app/assets/javascripts/error_tracking/store/list/mutations.js b/app/assets/javascripts/error_tracking/store/list/mutations.js index 5648013bb8964bedb81c40b39a45b27303ceafa0..dd5cde0576aad65c6906fc2998a36b8b92102e94 100644 --- a/app/assets/javascripts/error_tracking/store/list/mutations.js +++ b/app/assets/javascripts/error_tracking/store/list/mutations.js @@ -47,6 +47,9 @@ export default { [types.SET_PAGINATION](state, pagination) { state.pagination = pagination; }, + [types.SET_CURSOR](state, cursor) { + state.cursor = cursor; + }, [types.SET_SORT_FIELD](state, field) { state.sortField = field; }, diff --git a/app/assets/javascripts/error_tracking/store/list/state.js b/app/assets/javascripts/error_tracking/store/list/state.js index 93dc1040fde4895b15878c2078a7d3afe1c9e404..225a805e709c3ab24fbd7856546546ac416cbe0e 100644 --- a/app/assets/javascripts/error_tracking/store/list/state.js +++ b/app/assets/javascripts/error_tracking/store/list/state.js @@ -7,4 +7,5 @@ export default () => ({ indexPath: '', recentSearches: [], pagination: {}, + cursor: null, }); diff --git a/app/assets/javascripts/error_tracking/store/mutation_types.js b/app/assets/javascripts/error_tracking/store/mutation_types.js new file mode 100644 index 0000000000000000000000000000000000000000..30aebacbedd33915ba8c632be262e7c376d1de22 --- /dev/null +++ b/app/assets/javascripts/error_tracking/store/mutation_types.js @@ -0,0 +1,2 @@ +export const SET_UPDATING_RESOLVE_STATUS = 'SET_UPDATING_RESOLVE_STATUS'; +export const SET_UPDATING_IGNORE_STATUS = 'SET_UPDATING_IGNORE_STATUS'; diff --git a/app/assets/javascripts/error_tracking/store/mutations.js b/app/assets/javascripts/error_tracking/store/mutations.js new file mode 100644 index 0000000000000000000000000000000000000000..c7a7e46df403d39ba750ee6a9a1a24c0c89cd05b --- /dev/null +++ b/app/assets/javascripts/error_tracking/store/mutations.js @@ -0,0 +1,10 @@ +import * as types from './mutation_types'; + +export default { + [types.SET_UPDATING_IGNORE_STATUS](state, updating) { + state.updatingIgnoreStatus = updating; + }, + [types.SET_UPDATING_RESOLVE_STATUS](state, updating) { + state.updatingResolveStatus = updating; + }, +}; diff --git a/app/assets/javascripts/error_tracking_settings/components/project_dropdown.vue b/app/assets/javascripts/error_tracking_settings/components/project_dropdown.vue index 82df02afafd6f2e3b35d7c3265045f39166ce5e1..11fd06fb40b25ca57bd3251979cbcce90ac2194b 100644 --- a/app/assets/javascripts/error_tracking_settings/components/project_dropdown.vue +++ b/app/assets/javascripts/error_tracking_settings/components/project_dropdown.vue @@ -1,14 +1,11 @@ <script> -import { GlDropdown, GlDropdownHeader, GlDropdownItem } from '@gitlab/ui'; -import Icon from '~/vue_shared/components/icon.vue'; +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; import { getDisplayName } from '../utils'; export default { components: { GlDropdown, - GlDropdownHeader, GlDropdownItem, - Icon, }, props: { dropdownLabel: { diff --git a/app/assets/javascripts/error_tracking_settings/store/actions.js b/app/assets/javascripts/error_tracking_settings/store/actions.js index 6b540ea7dfd9f2863c318e36cb6e7a3fbd31a1ad..3f1ac426278ef5f1c5e318b03d78a88a1e6baa75 100644 --- a/app/assets/javascripts/error_tracking_settings/store/actions.js +++ b/app/assets/javascripts/error_tracking_settings/store/actions.js @@ -25,8 +25,8 @@ export const receiveProjectsError = ({ commit }) => { export const fetchProjects = ({ dispatch, state }) => { dispatch('requestProjects'); return axios - .post(state.listProjectsEndpoint, { - error_tracking_setting: { + .get(state.listProjectsEndpoint, { + params: { api_host: state.apiHost, token: state.token, }, diff --git a/app/assets/javascripts/feature_highlight/feature_highlight_options.js b/app/assets/javascripts/feature_highlight/feature_highlight_options.js index 212643b1e04990728a8b696b20dc5b9e99087689..c5553f0243f849fe9af8399553af4b787c9ba4ef 100644 --- a/app/assets/javascripts/feature_highlight/feature_highlight_options.js +++ b/app/assets/javascripts/feature_highlight/feature_highlight_options.js @@ -1,8 +1,8 @@ +import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import { highlightFeatures } from './feature_highlight'; -import bp from '../breakpoints'; export default function domContentLoaded() { - if (bp.getBreakpointSize() === 'lg') { + if (bp.getBreakpointSize() === 'xl') { highlightFeatures(); return true; } diff --git a/app/assets/javascripts/filterable_list.js b/app/assets/javascripts/filterable_list.js index c21fba06d42c50322a759e24ff267ce859aae7d2..be2eee828ff2b53211be65c38935372e7fa88eed 100644 --- a/app/assets/javascripts/filterable_list.js +++ b/app/assets/javascripts/filterable_list.js @@ -64,6 +64,7 @@ export default class FilterableList { return false; } + // eslint-disable-next-line no-jquery/no-fade $(this.listHolderElement).fadeTo(250, 0.5); this.isBusy = true; @@ -98,6 +99,7 @@ export default class FilterableList { onFilterComplete() { this.isBusy = false; + // eslint-disable-next-line no-jquery/no-fade $(this.listHolderElement).fadeTo(250, 1); } } diff --git a/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js b/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js index e020628a473f8ca5c3282f0bac3162c564d54240..9440015b32eb23b24745dcbb3e71f772aba382b0 100644 --- a/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js +++ b/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js @@ -2,6 +2,7 @@ import { __ } from '~/locale'; export default IssuableTokenKeys => { const wipToken = { + formattedKey: __('WIP'), key: 'wip', type: 'string', param: '', @@ -17,6 +18,7 @@ export default IssuableTokenKeys => { IssuableTokenKeys.tokenKeysWithAlternative.push(wipToken); const targetBranchToken = { + formattedKey: __('Target-Branch'), key: 'target-branch', type: 'string', param: '', diff --git a/app/assets/javascripts/filtered_search/admin_runners_filtered_search_token_keys.js b/app/assets/javascripts/filtered_search/admin_runners_filtered_search_token_keys.js index 691d165c5859d261199145a8e7fc00d4113e34bb..42d0fbacca058f1e946104ee5fbae6764c35da87 100644 --- a/app/assets/javascripts/filtered_search/admin_runners_filtered_search_token_keys.js +++ b/app/assets/javascripts/filtered_search/admin_runners_filtered_search_token_keys.js @@ -1,7 +1,9 @@ +import { __ } from '~/locale'; import FilteredSearchTokenKeys from './filtered_search_token_keys'; const tokenKeys = [ { + formattedKey: __('Status'), key: 'status', type: 'string', param: 'status', @@ -10,6 +12,7 @@ const tokenKeys = [ tag: 'status', }, { + formattedKey: __('Type'), key: 'type', type: 'string', param: 'type', @@ -18,6 +21,7 @@ const tokenKeys = [ tag: 'type', }, { + formattedKey: __('Tag'), key: 'tag', type: 'array', param: 'name[]', diff --git a/app/assets/javascripts/filtered_search/available_dropdown_mappings.js b/app/assets/javascripts/filtered_search/available_dropdown_mappings.js index 5fa07045d5ec4f17995ba525b075fdf528a82fb5..5450abf4cbd0e540c10672b130600b3d913e08fb 100644 --- a/app/assets/javascripts/filtered_search/available_dropdown_mappings.js +++ b/app/assets/javascripts/filtered_search/available_dropdown_mappings.js @@ -4,6 +4,7 @@ import DropdownNonUser from './dropdown_non_user'; import DropdownEmoji from './dropdown_emoji'; import NullDropdown from './null_dropdown'; import DropdownAjaxFilter from './dropdown_ajax_filter'; +import DropdownOperator from './dropdown_operator'; import DropdownUtils from './dropdown_utils'; import { mergeUrlParams } from '../lib/utils/url_utility'; @@ -40,6 +41,11 @@ export default class AvailableDropdownMappings { gl: DropdownHint, element: this.container.querySelector('#js-dropdown-hint'), }, + operator: { + reference: null, + gl: DropdownOperator, + element: this.container.querySelector('#js-dropdown-operator'), + }, }; supportedTokens.forEach(type => { diff --git a/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.vue b/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.vue index 4757c4b1e436c780adf09d9a75851ca7ef38fe2e..fa2609a31767759c88c26dbf25d23f60f0c2be36 100644 --- a/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.vue +++ b/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.vue @@ -29,6 +29,7 @@ export default { const resultantTokens = tokens.map(token => ({ prefix: `${token.key}:`, + operator: token.operator, suffix: `${token.symbol}${token.value}`, })); @@ -75,6 +76,7 @@ export default { class="filtered-search-history-dropdown-token" > <span class="name">{{ token.prefix }}</span> + <span class="name">{{ token.operator }}</span> <span class="value">{{ token.suffix }}</span> </span> </span> diff --git a/app/assets/javascripts/filtered_search/constants.js b/app/assets/javascripts/filtered_search/constants.js index b11111f1081171ae9a526c237e5242bbc7ce7ddb..d7264e96b131e81b17bca8df0573535d44c89cff 100644 --- a/app/assets/javascripts/filtered_search/constants.js +++ b/app/assets/javascripts/filtered_search/constants.js @@ -1,2 +1,6 @@ -/* eslint-disable import/prefer-default-export */ export const USER_TOKEN_TYPES = ['author', 'assignee']; + +export const DROPDOWN_TYPE = { + hint: 'hint', + operator: 'operator', +}; diff --git a/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js b/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js index b27bb63c22016ffa74796be40d29e6a8004f6358..92a64ab60db2445d9082ed4c16fa5cc5e59fea7b 100644 --- a/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js +++ b/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js @@ -45,7 +45,7 @@ export default class DropdownAjaxFilter extends FilteredSearchDropdown { getSearchInput() { const query = DropdownUtils.getSearchInput(this.input); - const { lastToken } = FilteredSearchTokenizer.processTokens(query, this.tokenKeys.get()); + const { lastToken } = FilteredSearchTokenizer.processTokens(query, this.tokenKeys.getKeys()); let value = lastToken || ''; diff --git a/app/assets/javascripts/filtered_search/dropdown_hint.js b/app/assets/javascripts/filtered_search/dropdown_hint.js index 1a1135ae9299febf19513e015ed54a3d135a4a55..4f10b6ba9c3e65f9e9d1e8efe4864ef664dabe0c 100644 --- a/app/assets/javascripts/filtered_search/dropdown_hint.js +++ b/app/assets/javascripts/filtered_search/dropdown_hint.js @@ -3,6 +3,7 @@ import FilteredSearchDropdown from './filtered_search_dropdown'; import DropdownUtils from './dropdown_utils'; import FilteredSearchDropdownManager from './filtered_search_dropdown_manager'; import FilteredSearchVisualTokens from './filtered_search_visual_tokens'; +import { __ } from '~/locale'; export default class DropdownHint extends FilteredSearchDropdown { constructor(options = {}) { @@ -30,8 +31,8 @@ export default class DropdownHint extends FilteredSearchDropdown { this.dismissDropdown(); this.dispatchFormSubmitEvent(); } else { - const token = selected.querySelector('.js-filter-hint').innerText.trim(); - const tag = selected.querySelector('.js-filter-tag').innerText.trim(); + const filterItemEl = selected.closest('.filter-dropdown-item'); + const { hint: token, tag } = filterItemEl.dataset; if (tag.length) { // Get previous input values in the input field and convert them into visual tokens @@ -55,8 +56,13 @@ export default class DropdownHint extends FilteredSearchDropdown { const key = token.replace(':', ''); const { uppercaseTokenName } = this.tokenKeys.searchByKey(key); - FilteredSearchDropdownManager.addWordToInput(key, '', false, { - uppercaseTokenName, + + FilteredSearchDropdownManager.addWordToInput({ + tokenName: key, + clicked: false, + options: { + uppercaseTokenName, + }, }); } this.dismissDropdown(); @@ -66,15 +72,30 @@ export default class DropdownHint extends FilteredSearchDropdown { } renderContent() { - const dropdownData = this.tokenKeys.get().map(tokenKey => ({ - icon: `${gon.sprite_icons}#${tokenKey.icon}`, - hint: tokenKey.key, - tag: `:${tokenKey.tag}`, - type: tokenKey.type, - })); + const searchItem = [ + { + hint: 'search', + tag: 'search', + formattedKey: __('Search for this text'), + icon: `${gon.sprite_icons}#search`, + }, + ]; + + const dropdownData = this.tokenKeys + .get() + .map(tokenKey => ({ + icon: `${gon.sprite_icons}#${tokenKey.icon}`, + hint: tokenKey.key, + tag: `:${tokenKey.tag}`, + type: tokenKey.type, + formattedKey: tokenKey.formattedKey, + })) + .concat(searchItem); this.droplab.changeHookList(this.hookId, this.dropdown, [Filter], this.config); this.droplab.setData(this.hookId, dropdownData); + + super.renderContent(); } init() { diff --git a/app/assets/javascripts/filtered_search/dropdown_operator.js b/app/assets/javascripts/filtered_search/dropdown_operator.js new file mode 100644 index 0000000000000000000000000000000000000000..bd4fda296092711c2bd8f65de16e568c0730cc02 --- /dev/null +++ b/app/assets/javascripts/filtered_search/dropdown_operator.js @@ -0,0 +1,65 @@ +import Filter from '~/droplab/plugins/filter'; +import { __ } from '~/locale'; +import FilteredSearchDropdown from './filtered_search_dropdown'; +import DropdownUtils from './dropdown_utils'; +import FilteredSearchDropdownManager from './filtered_search_dropdown_manager'; +import FilteredSearchVisualTokens from './filtered_search_visual_tokens'; + +export default class DropdownOperator extends FilteredSearchDropdown { + constructor(options = {}) { + const { input, tokenKeys } = options; + super(options); + + this.config = { + Filter: { + filterFunction: DropdownUtils.filterWithSymbol.bind(null, '', input), + template: 'title', + }, + }; + this.tokenKeys = tokenKeys; + } + + itemClicked(e) { + const { selected } = e.detail; + + if (selected.tagName === 'LI') { + if (selected.hasAttribute('data-value')) { + const operator = selected.dataset.value; + FilteredSearchVisualTokens.removeLastTokenPartial(); + FilteredSearchDropdownManager.addWordToInput({ + tokenName: this.filter, + tokenOperator: operator, + clicked: false, + }); + } + } + this.dismissDropdown(); + this.dispatchInputEvent(); + } + + renderContent(forceShowList = false) { + this.filter = FilteredSearchVisualTokens.getLastTokenPartial(); + + const dropdownData = [ + { + tag: 'equal', + type: 'string', + title: '=', + help: __('Is'), + }, + { + tag: 'not-equal', + type: 'string', + title: '!=', + help: __('Is not'), + }, + ]; + this.droplab.changeHookList(this.hookId, this.dropdown, [Filter], this.config); + this.droplab.setData(this.hookId, dropdownData); + super.renderContent(forceShowList); + } + + init() { + this.droplab.addHook(this.input, this.dropdown, [Filter], this.config).init(); + } +} diff --git a/app/assets/javascripts/filtered_search/dropdown_utils.js b/app/assets/javascripts/filtered_search/dropdown_utils.js index 8d92af2cf7e5a4e25deca89acced1b4709271261..274c08e6955aa6e34386d62a8ff9665c096e6482 100644 --- a/app/assets/javascripts/filtered_search/dropdown_utils.js +++ b/app/assets/javascripts/filtered_search/dropdown_utils.js @@ -62,28 +62,42 @@ export default class DropdownUtils { const lastKey = lastToken.key || lastToken || ''; const allowMultiple = item.type === 'array'; const itemInExistingTokens = tokens.some(t => t.key === item.hint); + const isSearchItem = updatedItem.hint === 'search'; + + if (isSearchItem) { + updatedItem.droplab_hidden = true; + } if (!allowMultiple && itemInExistingTokens) { updatedItem.droplab_hidden = true; - } else if (!lastKey || _.last(searchInput.split('')) === ' ') { + } else if (!isSearchItem && (!lastKey || _.last(searchInput.split('')) === ' ')) { updatedItem.droplab_hidden = false; } else if (lastKey) { const split = lastKey.split(':'); const tokenName = _.last(split[0].split(' ')); - const match = updatedItem.hint.indexOf(tokenName.toLowerCase()) === -1; + const match = isSearchItem + ? allowedKeys.some(key => key.startsWith(tokenName.toLowerCase())) + : updatedItem.hint.indexOf(tokenName.toLowerCase()) === -1; + updatedItem.droplab_hidden = tokenName ? match : false; } return updatedItem; } - static setDataValueIfSelected(filter, selected) { + static setDataValueIfSelected(filter, operator, selected) { const dataValue = selected.getAttribute('data-value'); if (dataValue) { - FilteredSearchDropdownManager.addWordToInput(filter, dataValue, true, { - capitalizeTokenValue: selected.hasAttribute('data-capitalize'), + FilteredSearchDropdownManager.addWordToInput({ + tokenName: filter, + tokenOperator: operator, + tokenValue: dataValue, + clicked: true, + options: { + capitalizeTokenValue: selected.hasAttribute('data-capitalize'), + }, }); } @@ -101,7 +115,11 @@ export default class DropdownUtils { // remove leading symbol and wrapping quotes tokenValue = tokenValue.replace(/^~("|')?(.*)/, '$2').replace(/("|')$/, ''); } - return { tokenName, tokenValue }; + + const operatorEl = visualToken && visualToken.querySelector('.operator'); + const tokenOperator = operatorEl && operatorEl.textContent.trim(); + + return { tokenName, tokenOperator, tokenValue }; } // Determines the full search query (visual tokens + input) @@ -119,10 +137,16 @@ export default class DropdownUtils { tokens.forEach(token => { if (token.classList.contains('js-visual-token')) { const name = token.querySelector('.name'); + const operatorContainer = token.querySelector('.operator'); const value = token.querySelector('.value'); const valueContainer = token.querySelector('.value-container'); const symbol = value && value.dataset.symbol ? value.dataset.symbol : ''; let valueText = ''; + let operator = ''; + + if (operatorContainer) { + operator = operatorContainer.textContent.trim(); + } if (valueContainer && valueContainer.dataset.originalValue) { valueText = valueContainer.dataset.originalValue; @@ -131,7 +155,7 @@ export default class DropdownUtils { } if (token.className.indexOf('filtered-search-token') !== -1) { - values.push(`${name.innerText.toLowerCase()}:${symbol}${valueText}`); + values.push(`${name.innerText.toLowerCase()}:${operator}${symbol}${valueText}`); } else { values.push(name.innerText); } diff --git a/app/assets/javascripts/filtered_search/filtered_search_dropdown.js b/app/assets/javascripts/filtered_search/filtered_search_dropdown.js index 146d3ba963c686149d74013bef70a59f4e8c77e7..72565c2ca134032faf5e0f6b024a99312bafc769 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_dropdown.js +++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown.js @@ -1,5 +1,6 @@ import DropdownUtils from './dropdown_utils'; import FilteredSearchDropdownManager from './filtered_search_dropdown_manager'; +import FilteredSearchVisualTokens from './filtered_search_visual_tokens'; const DATA_DROPDOWN_TRIGGER = 'data-dropdown-trigger'; @@ -31,13 +32,26 @@ export default class FilteredSearchDropdown { itemClicked(e, getValueFunction) { const { selected } = e.detail; - if (selected.tagName === 'LI' && selected.innerHTML) { - const dataValueSet = DropdownUtils.setDataValueIfSelected(this.filter, selected); + const { + lastVisualToken: visualToken, + } = FilteredSearchVisualTokens.getLastVisualTokenBeforeInput(); + const { tokenOperator } = DropdownUtils.getVisualTokenValues(visualToken); + + const dataValueSet = DropdownUtils.setDataValueIfSelected( + this.filter, + tokenOperator, + selected, + ); if (!dataValueSet) { const value = getValueFunction(selected); - FilteredSearchDropdownManager.addWordToInput(this.filter, value, true); + FilteredSearchDropdownManager.addWordToInput({ + tokenName: this.filter, + tokenOperator, + tokenValue: value, + clicked: true, + }); } this.resetFilters(); diff --git a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js index 5ff95f45be46cf58ba295670cf54949786cc58d8..566fb295588371ac08bed299aae0e97bdab4ac8a 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js @@ -5,6 +5,7 @@ import FilteredSearchContainer from './container'; import FilteredSearchTokenKeys from './filtered_search_token_keys'; import DropdownUtils from './dropdown_utils'; import FilteredSearchVisualTokens from './filtered_search_visual_tokens'; +import { DROPDOWN_TYPE } from './constants'; export default class FilteredSearchDropdownManager { constructor({ @@ -67,10 +68,16 @@ export default class FilteredSearchDropdownManager { this.mapping = availableMappings.getAllowedMappings(supportedTokens); } - static addWordToInput(tokenName, tokenValue = '', clicked = false, options = {}) { + static addWordToInput({ + tokenName, + tokenOperator = '', + tokenValue = '', + clicked = false, + options = {}, + }) { const { uppercaseTokenName = false, capitalizeTokenValue = false } = options; const input = FilteredSearchContainer.container.querySelector('.filtered-search'); - FilteredSearchVisualTokens.addFilterVisualToken(tokenName, tokenValue, { + FilteredSearchVisualTokens.addFilterVisualToken(tokenName, tokenOperator, tokenValue, { uppercaseTokenName, capitalizeTokenValue, }); @@ -129,7 +136,10 @@ export default class FilteredSearchDropdownManager { mappingKey.reference.init(); } - if (this.currentDropdown === 'hint') { + if ( + this.currentDropdown === DROPDOWN_TYPE.hint || + this.currentDropdown === DROPDOWN_TYPE.operator + ) { // Force the dropdown to show if it was clicked from the hint dropdown forceShowList = true; } @@ -148,13 +158,19 @@ export default class FilteredSearchDropdownManager { this.droplab = new DropLab(); } + if (dropdownName === DROPDOWN_TYPE.operator) { + this.load(dropdownName, firstLoad); + return; + } + const match = this.filteredSearchTokenKeys.searchByKey(dropdownName.toLowerCase()); const shouldOpenFilterDropdown = match && this.currentDropdown !== match.key && this.mapping[match.key]; - const shouldOpenHintDropdown = !match && this.currentDropdown !== 'hint'; + const shouldOpenHintDropdown = !match && this.currentDropdown !== DROPDOWN_TYPE.hint; if (shouldOpenFilterDropdown || shouldOpenHintDropdown) { - const key = match && match.key ? match.key : 'hint'; + const key = match && match.key ? match.key : DROPDOWN_TYPE.hint; + this.load(key, firstLoad); } } @@ -169,19 +185,32 @@ export default class FilteredSearchDropdownManager { if (this.currentDropdown) { this.updateCurrentDropdownOffset(); } - if (lastToken === searchToken && lastToken !== null) { // Token is not fully initialized yet because it has no value // Eg. token = 'label:' const split = lastToken.split(':'); const dropdownName = _.last(split[0].split(' ')); - this.loadDropdown(split.length > 1 ? dropdownName : ''); + const possibleOperatorToken = _.last(split[1]); + + const hasOperator = FilteredSearchVisualTokens.permissibleOperatorValues.includes( + possibleOperatorToken && possibleOperatorToken.trim(), + ); + + let dropdownToOpen = ''; + + if (split.length > 1) { + const lastOperatorToken = FilteredSearchVisualTokens.getLastTokenOperator(); + dropdownToOpen = hasOperator && lastOperatorToken ? dropdownName : DROPDOWN_TYPE.operator; + } + + this.loadDropdown(dropdownToOpen); } else if (lastToken) { + const lastOperator = FilteredSearchVisualTokens.getLastTokenOperator(); // Token has been initialized into an object because it has a value - this.loadDropdown(lastToken.key); + this.loadDropdown(lastOperator ? lastToken.key : DROPDOWN_TYPE.operator); } else { - this.loadDropdown('hint'); + this.loadDropdown(DROPDOWN_TYPE.hint); } } diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js index a4edc5fd52d3dea729760575b1170de2f3f57538..0b4f9457c545b0b03268e79cb5557b466cdff25d 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js @@ -14,6 +14,7 @@ import FilteredSearchTokenizer from './filtered_search_tokenizer'; import FilteredSearchDropdownManager from './filtered_search_dropdown_manager'; import FilteredSearchVisualTokens from './filtered_search_visual_tokens'; import DropdownUtils from './dropdown_utils'; +import { BACKSPACE_KEY_CODE } from '~/lib/utils/keycodes'; import { __ } from '~/locale'; export default class FilteredSearchManager { @@ -58,6 +59,8 @@ export default class FilteredSearchManager { this.recentSearchesService = new RecentSearchesService(recentSearchesKey); } + static notTransformableQueryParams = ['scope', 'utf8', 'state', 'search']; + setup() { // Fetch recent searches from localStorage this.fetchingRecentSearchesPromise = this.recentSearchesService @@ -84,6 +87,7 @@ export default class FilteredSearchManager { if (this.filteredSearchInput) { this.tokenizer = FilteredSearchTokenizer; + this.dropdownManager = new FilteredSearchDropdownManager({ runnerTagsEndpoint: this.filteredSearchInput.getAttribute('data-runner-tags-endpoint') || '', @@ -172,7 +176,7 @@ export default class FilteredSearchManager { this.filteredSearchInput.addEventListener('input', this.setDropdownWrapper); this.filteredSearchInput.addEventListener('input', this.toggleClearSearchButtonWrapper); this.filteredSearchInput.addEventListener('input', this.handleInputPlaceholderWrapper); - this.filteredSearchInput.addEventListener('input', this.handleInputVisualTokenWrapper); + this.filteredSearchInput.addEventListener('keyup', this.handleInputVisualTokenWrapper); this.filteredSearchInput.addEventListener('keydown', this.checkForEnterWrapper); this.filteredSearchInput.addEventListener('keyup', this.checkForBackspaceWrapper); this.filteredSearchInput.addEventListener('click', this.tokenChange); @@ -194,7 +198,7 @@ export default class FilteredSearchManager { this.filteredSearchInput.removeEventListener('input', this.setDropdownWrapper); this.filteredSearchInput.removeEventListener('input', this.toggleClearSearchButtonWrapper); this.filteredSearchInput.removeEventListener('input', this.handleInputPlaceholderWrapper); - this.filteredSearchInput.removeEventListener('input', this.handleInputVisualTokenWrapper); + this.filteredSearchInput.removeEventListener('keyup', this.handleInputVisualTokenWrapper); this.filteredSearchInput.removeEventListener('keydown', this.checkForEnterWrapper); this.filteredSearchInput.removeEventListener('keyup', this.checkForBackspaceWrapper); this.filteredSearchInput.removeEventListener('click', this.tokenChange); @@ -228,7 +232,7 @@ export default class FilteredSearchManager { if (backspaceCount === 2) { backspaceCount = 0; - this.filteredSearchInput.value = FilteredSearchVisualTokens.getLastTokenPartial(); + this.filteredSearchInput.value = FilteredSearchVisualTokens.getLastTokenPartial(true); FilteredSearchVisualTokens.removeLastTokenPartial(); } } @@ -407,7 +411,12 @@ export default class FilteredSearchManager { } } - handleInputVisualToken() { + handleInputVisualToken(e) { + // If the keyCode was 8 then do not form new tokens + if (e.keyCode === BACKSPACE_KEY_CODE) { + return; + } + const input = this.filteredSearchInput; const { tokens, searchToken } = this.tokenizer.processTokens( input.value, @@ -417,14 +426,21 @@ export default class FilteredSearchManager { if (isLastVisualTokenValid) { tokens.forEach(t => { - input.value = input.value.replace(`${t.key}:${t.symbol}${t.value}`, ''); - FilteredSearchVisualTokens.addFilterVisualToken(t.key, `${t.symbol}${t.value}`, { - uppercaseTokenName: this.filteredSearchTokenKeys.shouldUppercaseTokenName(t.key), - capitalizeTokenValue: this.filteredSearchTokenKeys.shouldCapitalizeTokenValue(t.key), - }); + input.value = input.value.replace(`${t.key}:${t.operator}${t.symbol}${t.value}`, ''); + + FilteredSearchVisualTokens.addFilterVisualToken( + t.key, + t.operator, + `${t.symbol}${t.value}`, + { + uppercaseTokenName: this.filteredSearchTokenKeys.shouldUppercaseTokenName(t.key), + capitalizeTokenValue: this.filteredSearchTokenKeys.shouldCapitalizeTokenValue(t.key), + }, + ); }); const fragments = searchToken.split(':'); + if (fragments.length > 1) { const inputValues = fragments[0].split(' '); const tokenKey = _.last(inputValues); @@ -437,19 +453,58 @@ export default class FilteredSearchManager { FilteredSearchVisualTokens.addSearchVisualToken(searchTerms); } - FilteredSearchVisualTokens.addFilterVisualToken(tokenKey, null, { + FilteredSearchVisualTokens.addFilterVisualToken(tokenKey, null, null, { uppercaseTokenName: this.filteredSearchTokenKeys.shouldUppercaseTokenName(tokenKey), capitalizeTokenValue: this.filteredSearchTokenKeys.shouldCapitalizeTokenValue(tokenKey), }); input.value = input.value.replace(`${tokenKey}:`, ''); } + + const splitSearchToken = searchToken && searchToken.split(' '); + let lastSearchToken = _.last(splitSearchToken); + lastSearchToken = lastSearchToken?.toLowerCase(); + + /** + * If user writes "milestone", a known token, in the input, we should not + * wait for leading colon to flush it as a filter token. + */ + if (this.filteredSearchTokenKeys.getKeys().includes(lastSearchToken)) { + if (splitSearchToken.length > 1) { + splitSearchToken.pop(); + const searchVisualTokens = splitSearchToken.join(' '); + + input.value = input.value.replace(searchVisualTokens, ''); + FilteredSearchVisualTokens.addSearchVisualToken(searchVisualTokens); + } + FilteredSearchVisualTokens.addFilterVisualToken(lastSearchToken, null, null, { + uppercaseTokenName: this.filteredSearchTokenKeys.shouldUppercaseTokenName( + lastSearchToken, + ), + capitalizeTokenValue: this.filteredSearchTokenKeys.shouldCapitalizeTokenValue( + lastSearchToken, + ), + }); + input.value = input.value.replace(lastSearchToken, ''); + } + } else if (!isLastVisualTokenValid && !FilteredSearchVisualTokens.getLastTokenOperator()) { + const tokenKey = FilteredSearchVisualTokens.getLastTokenPartial(); + const tokenOperator = searchToken && searchToken.trim(); + + // Tokenize operator only if the operator token is valid + if (FilteredSearchVisualTokens.permissibleOperatorValues.includes(tokenOperator)) { + FilteredSearchVisualTokens.removeLastTokenPartial(); + FilteredSearchVisualTokens.addFilterVisualToken(tokenKey, tokenOperator, null, { + capitalizeTokenValue: this.filteredSearchTokenKeys.shouldCapitalizeTokenValue(tokenKey), + }); + input.value = input.value.replace(searchToken, '').trim(); + } } else { // Keep listening to token until we determine that the user is done typing the token value const valueCompletedRegex = /([~%@]{0,1}".+")|([~%@]{0,1}'.+')|^((?![~%@]')(?![~%@]")(?!')(?!")).*/g; if (searchToken.match(valueCompletedRegex) && input.value[input.value.length - 1] === ' ') { const tokenKey = FilteredSearchVisualTokens.getLastTokenPartial(); - FilteredSearchVisualTokens.addFilterVisualToken(searchToken, null, { + FilteredSearchVisualTokens.addFilterVisualToken(searchToken, null, null, { capitalizeTokenValue: this.filteredSearchTokenKeys.shouldCapitalizeTokenValue(tokenKey), }); @@ -484,9 +539,52 @@ export default class FilteredSearchManager { return this.modifyUrlParams ? this.modifyUrlParams(urlParams) : urlParams; } + transformParams(params) { + /** + * Extract key, value pair from the `not` query param: + * Query param looks like not[key]=value + * + * Eg. not[foo]=%bar + * key = foo; value = %bar + */ + const notKeyValueRegex = new RegExp(/not\[(\w+)\]\[?\]?=(.*)/); + + return params.map(query => { + // Check if there are matches for `not` operator + const matches = query.match(notKeyValueRegex); + if (matches && matches.length === 3) { + const keyParam = matches[1]; + if ( + FilteredSearchManager.notTransformableQueryParams.includes(keyParam) || + this.filteredSearchTokenKeys.searchByConditionUrl(query) + ) { + return query; + } + + const valueParam = matches[2]; + // Not operator + const operator = encodeURIComponent('!='); + return `${keyParam}=${operator}${valueParam}`; + } + + const [keyParam, valueParam] = query.split('='); + + if ( + FilteredSearchManager.notTransformableQueryParams.includes(keyParam) || + this.filteredSearchTokenKeys.searchByConditionUrl(query) + ) { + return query; + } + + const operator = encodeURIComponent('='); + return `${keyParam}=${operator}${valueParam}`; + }); + } + loadSearchParamsFromURL() { const urlParams = getUrlParamsArray(); - const params = this.getAllParams(urlParams); + const withOperatorParams = this.transformParams(urlParams); + const params = this.getAllParams(withOperatorParams); const usernameParams = this.getUsernameParams(); let hasFilteredSearch = false; @@ -501,9 +599,14 @@ export default class FilteredSearchManager { if (condition) { hasFilteredSearch = true; const canEdit = this.canEdit && this.canEdit(condition.tokenKey); - FilteredSearchVisualTokens.addFilterVisualToken(condition.tokenKey, condition.value, { - canEdit, - }); + FilteredSearchVisualTokens.addFilterVisualToken( + condition.tokenKey, + condition.operator, + condition.value, + { + canEdit, + }, + ); } else { // Sanitize value since URL converts spaces into + // Replace before decode so that we know what was originally + versus the encoded + @@ -522,9 +625,12 @@ export default class FilteredSearchManager { hasFilteredSearch = true; const canEdit = this.canEdit && this.canEdit(key, sanitizedValue); const { uppercaseTokenName, capitalizeTokenValue } = match; + const operator = FilteredSearchVisualTokens.getOperatorToken(sanitizedValue); + const sanitizedToken = FilteredSearchVisualTokens.getValueToken(sanitizedValue); FilteredSearchVisualTokens.addFilterVisualToken( key, - `${symbol}${quotationsToUse}${sanitizedValue}${quotationsToUse}`, + operator, + `${symbol}${quotationsToUse}${sanitizedToken}${quotationsToUse}`, { canEdit, uppercaseTokenName, @@ -537,7 +643,10 @@ export default class FilteredSearchManager { hasFilteredSearch = true; const tokenName = 'assignee'; const canEdit = this.canEdit && this.canEdit(tokenName); - FilteredSearchVisualTokens.addFilterVisualToken(tokenName, `@${usernameParams[id]}`, { + const operator = FilteredSearchVisualTokens.getOperatorToken(usernameParams[id]); + const valueToken = FilteredSearchVisualTokens.getValueToken(usernameParams[id]); + + FilteredSearchVisualTokens.addFilterVisualToken(tokenName, operator, `@${valueToken}`, { canEdit, }); } @@ -547,7 +656,10 @@ export default class FilteredSearchManager { hasFilteredSearch = true; const tokenName = 'author'; const canEdit = this.canEdit && this.canEdit(tokenName); - FilteredSearchVisualTokens.addFilterVisualToken(tokenName, `@${usernameParams[id]}`, { + const operator = FilteredSearchVisualTokens.getOperatorToken(usernameParams[id]); + const valueToken = FilteredSearchVisualTokens.getValueToken(usernameParams[id]); + + FilteredSearchVisualTokens.addFilterVisualToken(tokenName, operator, `@${valueToken}`, { canEdit, }); } @@ -582,7 +694,6 @@ export default class FilteredSearchManager { search(state = null) { const paths = []; const searchQuery = DropdownUtils.getSearchQuery(); - this.saveCurrentSearchQuery(); const tokenKeys = this.filteredSearchTokenKeys.getKeys(); @@ -593,6 +704,7 @@ export default class FilteredSearchManager { tokens.forEach(token => { const condition = this.filteredSearchTokenKeys.searchByConditionKeyValue( token.key, + token.operator, token.value, ); const tokenConfig = this.filteredSearchTokenKeys.searchByKey(token.key) || {}; @@ -620,7 +732,16 @@ export default class FilteredSearchManager { tokenValue = tokenValue.slice(1, tokenValue.length - 1); } - tokenPath = `${keyParam}=${encodeURIComponent(tokenValue)}`; + if (token.operator === '!=') { + const isArrayParam = keyParam.endsWith('[]'); + + tokenPath = `not[${isArrayParam ? keyParam.slice(0, -2) : keyParam}]${ + isArrayParam ? '[]' : '' + }=${encodeURIComponent(tokenValue)}`; + } else { + // Default operator is `=` + tokenPath = `${keyParam}=${encodeURIComponent(tokenValue)}`; + } } paths.push(tokenPath); diff --git a/app/assets/javascripts/filtered_search/filtered_search_token_keys.js b/app/assets/javascripts/filtered_search/filtered_search_token_keys.js index 0a9579bf491c434b6a90374eaa3f6ea2dc4f363f..89fc8047b65dab7eace2d4d9badd970e98f951c4 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_token_keys.js +++ b/app/assets/javascripts/filtered_search/filtered_search_token_keys.js @@ -65,17 +65,20 @@ export default class FilteredSearchTokenKeys { return this.conditions.find(condition => condition.url === url) || null; } - searchByConditionKeyValue(key, value) { + searchByConditionKeyValue(key, operator, value) { return ( this.conditions.find( condition => - condition.tokenKey === key && condition.value.toLowerCase() === value.toLowerCase(), + condition.tokenKey === key && + condition.operator === operator && + condition.value.toLowerCase() === value.toLowerCase(), ) || null ); } addExtraTokensForIssues() { const confidentialToken = { + formattedKey: __('Confidential'), key: 'confidential', type: 'string', param: '', diff --git a/app/assets/javascripts/filtered_search/filtered_search_tokenizer.js b/app/assets/javascripts/filtered_search/filtered_search_tokenizer.js index b5c4cb15aac9a6a8c47f6ed3d2d28445567dccc1..963e8fe5df5d9e64dceb3bca894e1f8512b757cf 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_tokenizer.js +++ b/app/assets/javascripts/filtered_search/filtered_search_tokenizer.js @@ -2,10 +2,11 @@ import './filtered_search_token_keys'; export default class FilteredSearchTokenizer { static processTokens(input, allowedKeys) { - // Regex extracts `(token):(symbol)(value)` + // Regex extracts `(token):(operator)(symbol)(value)` // Values that start with a double quote must end in a double quote (same for single) + const tokenRegex = new RegExp( - `(${allowedKeys.join('|')}):([~%@]?)(?:('[^']*'{0,1})|("[^"]*"{0,1})|(\\S+))`, + `(${allowedKeys.join('|')}):(=|!=)?([~%@]?)(?:('[^']*'{0,1})|("[^"]*"{0,1})|(\\S+))`, 'g', ); const tokens = []; @@ -13,16 +14,22 @@ export default class FilteredSearchTokenizer { let lastToken = null; const searchToken = input - .replace(tokenRegex, (match, key, symbol, v1, v2, v3) => { + .replace(tokenRegex, (match, key, operator, symbol, v1, v2, v3) => { let tokenValue = v1 || v2 || v3; let tokenSymbol = symbol; let tokenIndex = ''; + let tokenOperator = operator; if (tokenValue === '~' || tokenValue === '%' || tokenValue === '@') { tokenSymbol = tokenValue; tokenValue = ''; } + if (tokenValue === '!=' || tokenValue === '=') { + tokenOperator = tokenValue; + tokenValue = ''; + } + tokenIndex = `${key}:${tokenValue}`; // Prevent adding duplicates @@ -33,6 +40,7 @@ export default class FilteredSearchTokenizer { key, value: tokenValue || '', symbol: tokenSymbol || '', + operator: tokenOperator || '', }); } @@ -43,13 +51,12 @@ export default class FilteredSearchTokenizer { if (tokens.length > 0) { const last = tokens[tokens.length - 1]; - const lastString = `${last.key}:${last.symbol}${last.value}`; + const lastString = `${last.key}:${last.operator}${last.symbol}${last.value}`; lastToken = input.lastIndexOf(lastString) === input.length - lastString.length ? last : searchToken; } else { lastToken = searchToken; } - return { tokens, lastToken, diff --git a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js index 7f6457242ef8fc9b65267b2ed01f020f75389095..d41d5a543b01e6115ce37e0e3742921a19050aa7 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js +++ b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js @@ -3,6 +3,32 @@ import { objectToQueryString } from '~/lib/utils/common_utils'; import FilteredSearchContainer from './container'; export default class FilteredSearchVisualTokens { + static permissibleOperatorValues = ['=', '!=']; + + static getOperatorToken(value) { + let token = null; + + FilteredSearchVisualTokens.permissibleOperatorValues.forEach(operatorToken => { + if (value.startsWith(operatorToken)) { + token = operatorToken; + } + }); + + return token; + } + + static getValueToken(value) { + let newValue = value; + + FilteredSearchVisualTokens.permissibleOperatorValues.forEach(operatorToken => { + if (value.startsWith(operatorToken)) { + newValue = value.slice(operatorToken.length); + } + }); + + return newValue; + } + static getLastVisualTokenBeforeInput() { const inputLi = FilteredSearchContainer.container.querySelector('.input-token'); const lastVisualToken = inputLi && inputLi.previousElementSibling; @@ -12,7 +38,9 @@ export default class FilteredSearchVisualTokens { isLastVisualTokenValid: lastVisualToken === null || lastVisualToken.className.indexOf('filtered-search-term') !== -1 || - (lastVisualToken && lastVisualToken.querySelector('.value') !== null), + (lastVisualToken && + lastVisualToken.querySelector('.operator') !== null && + lastVisualToken.querySelector('.value') !== null), }; } @@ -42,11 +70,17 @@ export default class FilteredSearchVisualTokens { } static createVisualTokenElementHTML(options = {}) { - const { canEdit = true, uppercaseTokenName = false, capitalizeTokenValue = false } = options; + const { + canEdit = true, + hasOperator = false, + uppercaseTokenName = false, + capitalizeTokenValue = false, + } = options; return ` <div class="${canEdit ? 'selectable' : 'hidden'}" role="button"> <div class="${uppercaseTokenName ? 'text-uppercase' : ''} name"></div> + ${hasOperator ? '<div class="operator"></div>' : ''} <div class="value-container"> <div class="${capitalizeTokenValue ? 'text-capitalize' : ''} value"></div> <div class="remove-token" role="button"> @@ -57,18 +91,18 @@ export default class FilteredSearchVisualTokens { `; } - static renderVisualTokenValue(parentElement, tokenName, tokenValue) { + static renderVisualTokenValue(parentElement, tokenName, tokenValue, tokenOperator) { const tokenType = tokenName.toLowerCase(); const tokenValueContainer = parentElement.querySelector('.value-container'); const tokenValueElement = tokenValueContainer.querySelector('.value'); tokenValueElement.innerText = tokenValue; - const visualTokenValue = new VisualTokenValue(tokenValue, tokenType); + const visualTokenValue = new VisualTokenValue(tokenValue, tokenType, tokenOperator); visualTokenValue.render(tokenValueContainer, tokenValueElement); } - static addVisualTokenElement(name, value, options = {}) { + static addVisualTokenElement({ name, operator, value, options = {} }) { const { isSearchTerm = false, canEdit, @@ -84,17 +118,32 @@ export default class FilteredSearchVisualTokens { li.classList.add(tokenClass); } + const hasOperator = Boolean(operator); + if (value) { li.innerHTML = FilteredSearchVisualTokens.createVisualTokenElementHTML({ canEdit, uppercaseTokenName, + operator, + hasOperator, capitalizeTokenValue, }); - FilteredSearchVisualTokens.renderVisualTokenValue(li, name, value); + FilteredSearchVisualTokens.renderVisualTokenValue(li, name, value, operator); } else { - li.innerHTML = `<div class="${uppercaseTokenName ? 'text-uppercase' : ''} name"></div>`; + const nameHTML = `<div class="${uppercaseTokenName ? 'text-uppercase' : ''} name"></div>`; + let operatorHTML = ''; + + if (hasOperator) { + operatorHTML = '<div class="operator"></div>'; + } + + li.innerHTML = nameHTML + operatorHTML; } + li.querySelector('.name').innerText = name; + if (hasOperator) { + li.querySelector('.operator').innerText = operator; + } const tokensContainer = FilteredSearchContainer.container.querySelector('.tokens-container'); const input = FilteredSearchContainer.container.querySelector('.filtered-search'); @@ -109,14 +158,19 @@ export default class FilteredSearchVisualTokens { if (!isLastVisualTokenValid && lastVisualToken.classList.contains('filtered-search-token')) { const name = FilteredSearchVisualTokens.getLastTokenPartial(); - lastVisualToken.innerHTML = FilteredSearchVisualTokens.createVisualTokenElementHTML(); + const operator = FilteredSearchVisualTokens.getLastTokenOperator(); + lastVisualToken.innerHTML = FilteredSearchVisualTokens.createVisualTokenElementHTML({ + hasOperator: Boolean(operator), + }); lastVisualToken.querySelector('.name').innerText = name; - FilteredSearchVisualTokens.renderVisualTokenValue(lastVisualToken, name, value); + lastVisualToken.querySelector('.operator').innerText = operator; + FilteredSearchVisualTokens.renderVisualTokenValue(lastVisualToken, name, value, operator); } } static addFilterVisualToken( tokenName, + tokenOperator, tokenValue, { canEdit, uppercaseTokenName = false, capitalizeTokenValue = false } = {}, ) { @@ -127,21 +181,51 @@ export default class FilteredSearchVisualTokens { const { addVisualTokenElement } = FilteredSearchVisualTokens; if (isLastVisualTokenValid) { - addVisualTokenElement(tokenName, tokenValue, { - canEdit, - uppercaseTokenName, - capitalizeTokenValue, + addVisualTokenElement({ + name: tokenName, + operator: tokenOperator, + value: tokenValue, + options: { + canEdit, + uppercaseTokenName, + capitalizeTokenValue, + }, + }); + } else if ( + !isLastVisualTokenValid && + (lastVisualToken && !lastVisualToken.querySelector('.operator')) + ) { + const tokensContainer = FilteredSearchContainer.container.querySelector('.tokens-container'); + tokensContainer.removeChild(lastVisualToken); + addVisualTokenElement({ + name: tokenName, + operator: tokenOperator, + value: tokenValue, + options: { + canEdit, + uppercaseTokenName, + capitalizeTokenValue, + }, }); } else { const previousTokenName = lastVisualToken.querySelector('.name').innerText; + const previousTokenOperator = lastVisualToken.querySelector('.operator').innerText; const tokensContainer = FilteredSearchContainer.container.querySelector('.tokens-container'); tokensContainer.removeChild(lastVisualToken); - const value = tokenValue || tokenName; - addVisualTokenElement(previousTokenName, value, { - canEdit, - uppercaseTokenName, - capitalizeTokenValue, + let value = tokenValue; + if (!value && !tokenOperator) { + value = tokenName; + } + addVisualTokenElement({ + name: previousTokenName, + operator: previousTokenOperator, + value, + options: { + canEdit, + uppercaseTokenName, + capitalizeTokenValue, + }, }); } } @@ -152,13 +236,18 @@ export default class FilteredSearchVisualTokens { if (lastVisualToken && lastVisualToken.classList.contains('filtered-search-term')) { lastVisualToken.querySelector('.name').innerText += ` ${searchTerm}`; } else { - FilteredSearchVisualTokens.addVisualTokenElement(searchTerm, null, { - isSearchTerm: true, + FilteredSearchVisualTokens.addVisualTokenElement({ + name: searchTerm, + operator: null, + value: null, + options: { + isSearchTerm: true, + }, }); } } - static getLastTokenPartial() { + static getLastTokenPartial(includeOperator = false) { const { lastVisualToken } = FilteredSearchVisualTokens.getLastVisualTokenBeforeInput(); if (!lastVisualToken) return ''; @@ -175,20 +264,36 @@ export default class FilteredSearchVisualTokens { const valueText = value ? value.innerText : ''; const nameText = name ? name.innerText : ''; + if (includeOperator) { + const operator = lastVisualToken.querySelector('.operator'); + const operatorText = operator ? operator.innerText : ''; + return valueText || operatorText || nameText; + } + return valueText || nameText; } + static getLastTokenOperator() { + const { lastVisualToken } = FilteredSearchVisualTokens.getLastVisualTokenBeforeInput(); + + const operator = lastVisualToken && lastVisualToken.querySelector('.operator'); + + return operator?.innerText; + } + static removeLastTokenPartial() { const { lastVisualToken } = FilteredSearchVisualTokens.getLastVisualTokenBeforeInput(); if (lastVisualToken) { const value = lastVisualToken.querySelector('.value'); - + const operator = lastVisualToken.querySelector('.operator'); if (value) { const button = lastVisualToken.querySelector('.selectable'); const valueContainer = lastVisualToken.querySelector('.value-container'); button.removeChild(valueContainer); lastVisualToken.innerHTML = button.innerHTML; + } else if (operator) { + lastVisualToken.removeChild(operator); } else { lastVisualToken.closest('.tokens-container').removeChild(lastVisualToken); } @@ -236,12 +341,18 @@ export default class FilteredSearchVisualTokens { tokenContainer.replaceChild(inputLi, token); const nameElement = token.querySelector('.name'); + const operatorElement = token.querySelector('.operator'); let value; if (token.classList.contains('filtered-search-token')) { - FilteredSearchVisualTokens.addFilterVisualToken(nameElement.innerText, null, { - uppercaseTokenName: nameElement.classList.contains('text-uppercase'), - }); + FilteredSearchVisualTokens.addFilterVisualToken( + nameElement.innerText, + operatorElement.innerText, + null, + { + uppercaseTokenName: nameElement.classList.contains('text-uppercase'), + }, + ); const valueContainerElement = token.querySelector('.value-container'); value = valueContainerElement.dataset.originalValue; 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 414bcf186a3b4a538dfe3fbac77333a9f938c56f..8722fc64b62198bbc6c3ec036c8e173a4fe76ad0 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 @@ -1,10 +1,10 @@ +import { flatten } from 'underscore'; import FilteredSearchTokenKeys from './filtered_search_token_keys'; import { __ } from '~/locale'; -export const tokenKeys = []; - -tokenKeys.push( +export const tokenKeys = [ { + formattedKey: __('Author'), key: 'author', type: 'string', param: 'username', @@ -13,6 +13,7 @@ tokenKeys.push( tag: '@author', }, { + formattedKey: __('Assignee'), key: 'assignee', type: 'string', param: 'username', @@ -21,6 +22,7 @@ tokenKeys.push( tag: '@assignee', }, { + formattedKey: __('Milestone'), key: 'milestone', type: 'string', param: 'title', @@ -28,31 +30,30 @@ tokenKeys.push( icon: 'clock', tag: '%milestone', }, -); - -if (gon && gon.features && gon.features.releaseSearchFilter) { - tokenKeys.push({ + { + formattedKey: __('Release'), key: 'release', type: 'string', param: 'tag', symbol: '', icon: 'rocket', tag: __('tag name'), - }); -} - -tokenKeys.push({ - key: 'label', - type: 'array', - param: 'name[]', - symbol: '~', - icon: 'labels', - tag: '~label', -}); + }, + { + formattedKey: __('Label'), + key: 'label', + type: 'array', + param: 'name[]', + symbol: '~', + icon: 'labels', + tag: '~label', + }, +]; if (gon.current_user_id) { // Appending tokenkeys only logged-in tokenKeys.push({ + formattedKey: __('My-Reaction'), key: 'my-reaction', type: 'string', param: 'emoji', @@ -64,6 +65,7 @@ if (gon.current_user_id) { export const alternativeTokenKeys = [ { + formattedKey: __('Label'), key: 'label', type: 'string', param: 'name', @@ -71,68 +73,88 @@ export const alternativeTokenKeys = [ }, ]; -export const conditions = [ - { - url: 'assignee_id=None', - tokenKey: 'assignee', - value: __('None'), - }, - { - url: 'assignee_id=Any', - tokenKey: 'assignee', - value: __('Any'), - }, - { - url: 'milestone_title=None', - tokenKey: 'milestone', - value: __('None'), - }, - { - url: 'milestone_title=Any', - tokenKey: 'milestone', - value: __('Any'), - }, - { - url: 'milestone_title=%23upcoming', - tokenKey: 'milestone', - value: __('Upcoming'), - }, - { - url: 'milestone_title=%23started', - tokenKey: 'milestone', - value: __('Started'), - }, - { - url: 'release_tag=None', - tokenKey: 'release', - value: __('None'), - }, - { - url: 'release_tag=Any', - tokenKey: 'release', - value: __('Any'), - }, - { - url: 'label_name[]=None', - tokenKey: 'label', - value: __('None'), - }, - { - url: 'label_name[]=Any', - tokenKey: 'label', - value: __('Any'), - }, - { - url: 'my_reaction_emoji=None', - tokenKey: 'my-reaction', - value: __('None'), - }, - { - url: 'my_reaction_emoji=Any', - tokenKey: 'my-reaction', - value: __('Any'), - }, -]; +export const conditions = flatten( + [ + { + url: 'assignee_id=None', + tokenKey: 'assignee', + value: __('None'), + }, + { + url: 'assignee_id=Any', + tokenKey: 'assignee', + value: __('Any'), + }, + { + url: 'milestone_title=None', + tokenKey: 'milestone', + value: __('None'), + }, + { + url: 'milestone_title=Any', + tokenKey: 'milestone', + value: __('Any'), + }, + { + url: 'milestone_title=%23upcoming', + tokenKey: 'milestone', + value: __('Upcoming'), + }, + { + url: 'milestone_title=%23started', + tokenKey: 'milestone', + value: __('Started'), + }, + { + url: 'release_tag=None', + tokenKey: 'release', + value: __('None'), + }, + { + url: 'release_tag=Any', + tokenKey: 'release', + value: __('Any'), + }, + { + url: 'label_name[]=None', + tokenKey: 'label', + value: __('None'), + }, + { + url: 'label_name[]=Any', + tokenKey: 'label', + value: __('Any'), + }, + { + url: 'my_reaction_emoji=None', + tokenKey: 'my-reaction', + value: __('None'), + }, + { + url: 'my_reaction_emoji=Any', + tokenKey: 'my-reaction', + value: __('Any'), + }, + ].map(condition => { + const [keyPart, valuePart] = condition.url.split('='); + const hasBrackets = keyPart.includes('[]'); + + const notEqualUrl = `not[${hasBrackets ? keyPart.slice(0, -2) : keyPart}]${ + hasBrackets ? '[]' : '' + }=${valuePart}`; + return [ + { + ...condition, + operator: '=', + }, + { + ...condition, + operator: '!=', + url: notEqualUrl, + }, + ]; + }), +); const IssuableFilteredSearchTokenKeys = new FilteredSearchTokenKeys( tokenKeys, diff --git a/app/assets/javascripts/filtered_search/visual_token_value.js b/app/assets/javascripts/filtered_search/visual_token_value.js index 1343ccd6468336523c118b082ef65d9965400e28..9f3cf881af4e6e3f385af69f6841dab2ac60273a 100644 --- a/app/assets/javascripts/filtered_search/visual_token_value.js +++ b/app/assets/javascripts/filtered_search/visual_token_value.js @@ -9,9 +9,10 @@ import UsersCache from '~/lib/utils/users_cache'; import { __ } from '~/locale'; export default class VisualTokenValue { - constructor(tokenValue, tokenType) { + constructor(tokenValue, tokenType, tokenOperator) { this.tokenValue = tokenValue; this.tokenType = tokenType; + this.tokenOperator = tokenOperator; } render(tokenValueContainer, tokenValueElement) { diff --git a/app/assets/javascripts/fly_out_nav.js b/app/assets/javascripts/fly_out_nav.js index 2566ed6b47c42cec84f11bc08d6c68446c904aea..b9ce08515852bfd3e8a95c04ca68654d04efae72 100644 --- a/app/assets/javascripts/fly_out_nav.js +++ b/app/assets/javascripts/fly_out_nav.js @@ -1,4 +1,4 @@ -import bp from './breakpoints'; +import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import { SIDEBAR_COLLAPSED_CLASS } from './contextual_sidebar'; const HIDE_INTERVAL_TIMEOUT = 300; @@ -40,10 +40,7 @@ export const canShowActiveSubItems = el => { return true; }; -export const canShowSubItems = () => - bp.getBreakpointSize() === 'sm' || - bp.getBreakpointSize() === 'md' || - bp.getBreakpointSize() === 'lg'; +export const canShowSubItems = () => ['md', 'lg', 'xl'].includes(bp.getBreakpointSize()); export const getHideSubItemsInterval = () => { if (!currentOpenMenu || !mousePos.length) return 0; diff --git a/app/assets/javascripts/frequent_items/utils.js b/app/assets/javascripts/frequent_items/utils.js index aba692e4b9967490cd70e97c88a48dfa29214317..cc1668b1a0dbde016d18f628fabd48d0acfe7e47 100644 --- a/app/assets/javascripts/frequent_items/utils.js +++ b/app/assets/javascripts/frequent_items/utils.js @@ -1,12 +1,8 @@ import _ from 'underscore'; -import bp from '~/breakpoints'; +import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import { FREQUENT_ITEMS, HOUR_IN_MS } from './constants'; -export const isMobile = () => { - const screenSize = bp.getBreakpointSize(); - - return screenSize === 'sm' || screenSize === 'xs'; -}; +export const isMobile = () => ['md', 'sm', 'xs'].includes(bp.getBreakpointSize()); export const getTopFrequentItems = items => { if (!items) { diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js index e25c9d90f60e22edbfad6d1911cd02b57f672b4b..de69daf5c22ae630d73461e7932ab8ed6ed26fee 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -107,8 +107,13 @@ class GfmAutoComplete { if (value.params.length > 0) { tpl += ' <small class="params"><%- params.join(" ") %></small>'; } - if (value.description !== '') { - tpl += '<small class="description"><i><%- description %> <%- warningText %></i></small>'; + if (value.warning && value.icon && value.icon === 'confidential') { + tpl += + '<small class="description"><em><i class="fa fa-eye-slash" aria-hidden="true"/><%- warning %></em></small>'; + } else if (value.warning) { + tpl += '<small class="description"><em><%- warning %></em></small>'; + } else if (value.description !== '') { + tpl += '<small class="description"><em><%- description %></em></small>'; } tpl += '</li>'; @@ -119,7 +124,6 @@ class GfmAutoComplete { return _.template(tpl)({ ...value, className: cssClasses.join(' '), - warningText: value.warning ? `(${value.warning})` : '', }); }, insertTpl(value) { @@ -150,6 +154,7 @@ class GfmAutoComplete { params: c.params, description: c.description, warning: c.warning, + icon: c.icon, search, }; }); diff --git a/app/assets/javascripts/grafana_integration/components/grafana_integration.vue b/app/assets/javascripts/grafana_integration/components/grafana_integration.vue index 6258ee7f153a9fb4ea8ac8819d1b1159dce084c6..41d83e45c520ae12cdad2f142b3d96c234a67a69 100644 --- a/app/assets/javascripts/grafana_integration/components/grafana_integration.vue +++ b/app/assets/javascripts/grafana_integration/components/grafana_integration.vue @@ -1,5 +1,5 @@ <script> -import { GlButton, GlFormGroup, GlFormInput, GlFormCheckbox, GlLink } from '@gitlab/ui'; +import { GlButton, GlFormGroup, GlFormInput, GlFormCheckbox } from '@gitlab/ui'; import { mapState, mapActions } from 'vuex'; import Icon from '~/vue_shared/components/icon.vue'; @@ -9,7 +9,6 @@ export default { GlFormCheckbox, GlFormGroup, GlFormInput, - GlLink, Icon, }, data() { diff --git a/app/assets/javascripts/groups/components/item_stats.vue b/app/assets/javascripts/groups/components/item_stats.vue index 675552e6c2bb6741721313b5c3ab36d3b8428b1d..53da3f7b2eea58b398b6050bc119721afbbd4320 100644 --- a/app/assets/javascripts/groups/components/item_stats.vue +++ b/app/assets/javascripts/groups/components/item_stats.vue @@ -1,5 +1,4 @@ <script> -import icon from '~/vue_shared/components/icon.vue'; import { GlBadge } from '@gitlab/ui'; import timeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import { @@ -13,7 +12,6 @@ import isProjectPendingRemoval from 'ee_else_ce/groups/mixins/is_project_pending export default { components: { - icon, timeAgoTooltip, itemStatsValue, GlBadge, diff --git a/app/assets/javascripts/helpers/diffs_helper.js b/app/assets/javascripts/helpers/diffs_helper.js new file mode 100644 index 0000000000000000000000000000000000000000..9695d01ad3dbd958ec9f6508bfa72f2b495b0990 --- /dev/null +++ b/app/assets/javascripts/helpers/diffs_helper.js @@ -0,0 +1,19 @@ +export function hasInlineLines(diffFile) { + return diffFile?.highlighted_diff_lines?.length > 0; /* eslint-disable-line camelcase */ +} + +export function hasParallelLines(diffFile) { + return diffFile?.parallel_diff_lines?.length > 0; /* eslint-disable-line camelcase */ +} + +export function isSingleViewStyle(diffFile) { + return !hasParallelLines(diffFile) || !hasInlineLines(diffFile); +} + +export function hasDiff(diffFile) { + return ( + hasInlineLines(diffFile) || + hasParallelLines(diffFile) || + !diffFile?.blob?.readable_text /* eslint-disable-line camelcase */ + ); +} diff --git a/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue b/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue index 6b2ef34c9607c58a3dc6a466fd373851cf898ddf..3398cd091badcba36c445651e838aa5c8bcfd7ac 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue @@ -1,12 +1,13 @@ <script> -import $ from 'jquery'; import { mapActions } from 'vuex'; -import { __ } from '~/locale'; +import { sprintf, __ } from '~/locale'; +import { GlModal } from '@gitlab/ui'; import FileIcon from '~/vue_shared/components/file_icon.vue'; import ChangedFileIcon from '~/vue_shared/components/changed_file_icon.vue'; export default { components: { + GlModal, FileIcon, ChangedFileIcon, }, @@ -17,7 +18,13 @@ export default { }, }, computed: { - activeButtonText() { + discardModalId() { + return `discard-file-${this.activeFile.path}`; + }, + discardModalTitle() { + return sprintf(__('Discard changes to %{path}?'), { path: this.activeFile.path }); + }, + actionButtonText() { return this.activeFile.staged ? __('Unstage') : __('Stage'); }, isStaged() { @@ -25,7 +32,7 @@ export default { }, }, methods: { - ...mapActions(['stageChange', 'unstageChange']), + ...mapActions(['stageChange', 'unstageChange', 'discardFileChanges']), actionButtonClicked() { if (this.activeFile.staged) { this.unstageChange(this.activeFile.path); @@ -34,7 +41,7 @@ export default { } }, showDiscardModal() { - $(document.getElementById(`discard-file-${this.activeFile.path}`)).modal('show'); + this.$refs.discardModal.show(); }, }, }; @@ -53,6 +60,7 @@ export default { <div class="ml-auto"> <button v-if="!isStaged" + ref="discardButton" type="button" class="btn btn-remove btn-inverted append-right-8" @click="showDiscardModal" @@ -60,6 +68,7 @@ export default { {{ __('Discard') }} </button> <button + ref="actionButton" :class="{ 'btn-success': !isStaged, 'btn-warning': isStaged, @@ -68,8 +77,19 @@ export default { class="btn btn-inverted" @click="actionButtonClicked" > - {{ activeButtonText }} + {{ actionButtonText }} </button> </div> + <gl-modal + ref="discardModal" + ok-variant="danger" + cancel-variant="light" + :ok-title="__('Discard changes')" + :modal-id="discardModalId" + :title="discardModalTitle" + @ok="discardFileChanges(activeFile.path)" + > + {{ __("You will lose all changes you've made to this file. This action cannot be undone.") }} + </gl-modal> </div> </template> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/form.vue b/app/assets/javascripts/ide/components/commit_sidebar/form.vue index f7ed7006874eb5d8d30d05cd1bc2f2d398bc91ef..9d5473a1201dfa31b7ceea53b5ef62c86f8f3e2e 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/form.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/form.vue @@ -6,6 +6,7 @@ import CommitMessageField from './message_field.vue'; import Actions from './actions.vue'; import SuccessMessage from './success_message.vue'; import { activityBarViews, MAX_WINDOW_HEIGHT_COMPACT } from '../../constants'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { components: { @@ -14,6 +15,7 @@ export default { CommitMessageField, SuccessMessage, }, + mixins: [glFeatureFlagsMixin()], data() { return { isCompact: true, @@ -27,9 +29,13 @@ export default { ...mapGetters('commit', ['discardDraftButtonDisabled', 'preBuiltCommitMessage']), overviewText() { return sprintf( - __( - '<strong>%{changedFilesLength} unstaged</strong> and <strong>%{stagedFilesLength} staged</strong> changes', - ), + this.glFeatures.stageAllByDefault + ? __( + '<strong>%{stagedFilesLength} staged</strong> and <strong>%{changedFilesLength} unstaged</strong> changes', + ) + : __( + '<strong>%{changedFilesLength} unstaged</strong> and <strong>%{stagedFilesLength} staged</strong> changes', + ), { stagedFilesLength: this.stagedFiles.length, changedFilesLength: this.changedFiles.length, @@ -39,6 +45,10 @@ export default { commitButtonText() { return this.stagedFiles.length ? __('Commit') : __('Stage & Commit'); }, + + currentViewIsCommitView() { + return this.currentActivityView === activityBarViews.commit; + }, }, watch: { currentActivityView() { @@ -46,11 +56,11 @@ export default { this.isCompact = false; } else { this.isCompact = !( - this.currentActivityView === activityBarViews.commit && - window.innerHeight >= MAX_WINDOW_HEIGHT_COMPACT + this.currentViewIsCommitView && window.innerHeight >= MAX_WINDOW_HEIGHT_COMPACT ); } }, + lastCommitMsg() { this.isCompact = this.currentActivityView !== activityBarViews.commit && this.lastCommitMsg === ''; @@ -59,14 +69,18 @@ export default { methods: { ...mapActions(['updateActivityBarView']), ...mapActions('commit', ['updateCommitMessage', 'discardDraft', 'commitChanges']), - toggleIsSmall() { - this.updateActivityBarView(activityBarViews.commit) - .then(() => { - this.isCompact = !this.isCompact; - }) - .catch(e => { - throw e; - }); + toggleIsCompact() { + if (this.currentViewIsCommitView) { + this.isCompact = !this.isCompact; + } else { + this.updateActivityBarView(activityBarViews.commit) + .then(() => { + this.isCompact = false; + }) + .catch(e => { + throw e; + }); + } }, beforeEnterTransition() { const elHeight = this.isCompact @@ -114,7 +128,7 @@ export default { :disabled="!hasChanges" type="button" class="btn btn-primary btn-sm btn-block qa-begin-commit-button" - @click="toggleIsSmall" + @click="toggleIsCompact" > {{ __('Commit…') }} </button> @@ -148,7 +162,7 @@ export default { v-else type="button" class="btn btn-default btn-sm float-right" - @click="toggleIsSmall" + @click="toggleIsCompact" > {{ __('Collapse') }} </button> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list.vue b/app/assets/javascripts/ide/components/commit_sidebar/list.vue index e16918ae025d6d5fc474f5d92565d9aba201c536..d9a385a9d312a0bbaa7d4623583b50f84871e70f 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/list.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/list.vue @@ -41,10 +41,6 @@ export default { type: String, required: true, }, - itemActionComponent: { - type: String, - required: true, - }, stagedList: { type: Boolean, required: false, @@ -142,7 +138,6 @@ export default { <li v-for="file in fileList" :key="file.key"> <list-item :file="file" - :action-component="itemActionComponent" :key-prefix="keyPrefix" :staged-list="stagedList" :active-file-key="activeFileKey" diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue index 230dfaf047bc8ab6dca1e3d51c306d792c1f8ba0..726e2b7e1fc6ec4d40be70aa0665f0cbe173c797 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue @@ -3,16 +3,12 @@ import { mapActions } from 'vuex'; import tooltip from '~/vue_shared/directives/tooltip'; import Icon from '~/vue_shared/components/icon.vue'; import FileIcon from '~/vue_shared/components/file_icon.vue'; -import StageButton from './stage_button.vue'; -import UnstageButton from './unstage_button.vue'; import { viewerTypes } from '../../constants'; import { getCommitIconMap } from '../../utils'; export default { components: { Icon, - StageButton, - UnstageButton, FileIcon, }, directives: { @@ -23,10 +19,6 @@ export default { type: Object, required: true, }, - actionComponent: { - type: String, - required: true, - }, keyPrefix: { type: String, required: false, diff --git a/app/assets/javascripts/ide/components/commit_sidebar/stage_button.vue b/app/assets/javascripts/ide/components/commit_sidebar/stage_button.vue deleted file mode 100644 index c14b8a47841076b82f4972425050106749c75b37..0000000000000000000000000000000000000000 --- a/app/assets/javascripts/ide/components/commit_sidebar/stage_button.vue +++ /dev/null @@ -1,78 +0,0 @@ -<script> -import $ from 'jquery'; -import { mapActions } from 'vuex'; -import { sprintf, __ } from '~/locale'; -import Icon from '~/vue_shared/components/icon.vue'; -import tooltip from '~/vue_shared/directives/tooltip'; -import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue'; - -export default { - components: { - Icon, - GlModal: DeprecatedModal2, - }, - directives: { - tooltip, - }, - props: { - path: { - type: String, - required: true, - }, - }, - computed: { - modalId() { - return `discard-file-${this.path}`; - }, - modalTitle() { - return sprintf(__('Discard changes to %{path}?'), { path: this.path }); - }, - }, - methods: { - ...mapActions(['stageChange', 'discardFileChanges']), - showDiscardModal() { - $(document.getElementById(this.modalId)).modal('show'); - }, - }, -}; -</script> - -<template> - <div v-once class="multi-file-discard-btn d-flex"> - <button - v-tooltip - :aria-label="__('Stage changes')" - :title="__('Stage changes')" - type="button" - class="btn btn-blank align-items-center" - data-container="body" - data-boundary="viewport" - data-placement="bottom" - @click.stop.prevent="stageChange(path)" - > - <icon :size="16" name="mobile-issue-close" class="ml-auto mr-auto" /> - </button> - <button - v-tooltip - :aria-label="__('Discard changes')" - :title="__('Discard changes')" - type="button" - class="btn btn-blank align-items-center" - data-container="body" - data-boundary="viewport" - data-placement="bottom" - @click.stop.prevent="showDiscardModal" - > - <icon :size="16" name="remove" class="ml-auto mr-auto" /> - </button> - <gl-modal - :id="modalId" - :header-title-text="modalTitle" - :footer-primary-button-text="__('Discard changes')" - footer-primary-button-variant="danger" - @submit="discardFileChanges(path)" - > - {{ __("You will lose all changes you've made to this file. This action cannot be undone.") }} - </gl-modal> - </div> -</template> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/unstage_button.vue b/app/assets/javascripts/ide/components/commit_sidebar/unstage_button.vue deleted file mode 100644 index 0567ef54ff318ba998903f89a051a9bc6f45b86a..0000000000000000000000000000000000000000 --- a/app/assets/javascripts/ide/components/commit_sidebar/unstage_button.vue +++ /dev/null @@ -1,41 +0,0 @@ -<script> -import { mapActions } from 'vuex'; -import Icon from '~/vue_shared/components/icon.vue'; -import tooltip from '~/vue_shared/directives/tooltip'; - -export default { - components: { - Icon, - }, - directives: { - tooltip, - }, - props: { - path: { - type: String, - required: true, - }, - }, - methods: { - ...mapActions(['unstageChange']), - }, -}; -</script> - -<template> - <div v-once class="multi-file-discard-btn d-flex"> - <button - v-tooltip - :aria-label="__('Unstage changes')" - :title="__('Unstage changes')" - type="button" - class="btn btn-blank align-items-center" - data-container="body" - data-boundary="viewport" - data-placement="bottom" - @click.stop.prevent="unstageChange(path)" - > - <icon :size="16" name="redo" class="ml-auto mr-auto" /> - </button> - </div> -</template> diff --git a/app/assets/javascripts/ide/components/file_row_extra.vue b/app/assets/javascripts/ide/components/file_row_extra.vue index f0bedcfbd6bbd36a95f82caa8267f9a819d12c94..33098eb1af05207e3c9e70fecfb9bb4b6f05acc1 100644 --- a/app/assets/javascripts/ide/components/file_row_extra.vue +++ b/app/assets/javascripts/ide/components/file_row_extra.vue @@ -6,6 +6,7 @@ import Icon from '~/vue_shared/components/icon.vue'; import ChangedFileIcon from '~/vue_shared/components/changed_file_icon.vue'; import NewDropdown from './new_dropdown/index.vue'; import MrFileIcon from './mr_file_icon.vue'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { name: 'FileRowExtra', @@ -18,6 +19,7 @@ export default { ChangedFileIcon, MrFileIcon, }, + mixins: [glFeatureFlagsMixin()], props: { file: { type: Object, @@ -55,10 +57,15 @@ export default { return n__('%d staged change', '%d staged changes', this.folderStagedCount); } - return sprintf(__('%{unstaged} unstaged and %{staged} staged changes'), { - unstaged: this.folderUnstagedCount, - staged: this.folderStagedCount, - }); + return sprintf( + this.glFeatures.stageAllByDefault + ? __('%{staged} staged and %{unstaged} unstaged changes') + : __('%{unstaged} unstaged and %{staged} staged changes'), + { + unstaged: this.folderUnstagedCount, + staged: this.folderStagedCount, + }, + ); }, showTreeChangesCount() { return this.isTree && this.changesCount > 0 && !this.file.opened; diff --git a/app/assets/javascripts/ide/components/ide.vue b/app/assets/javascripts/ide/components/ide.vue index 363a8f4303395c1ca0ec0c5784091f9f91e9e864..6ed863c9c2e4e8646978bdfcad160b376a20829a 100644 --- a/app/assets/javascripts/ide/components/ide.vue +++ b/app/assets/javascripts/ide/components/ide.vue @@ -1,6 +1,6 @@ <script> import Vue from 'vue'; -import { mapActions, mapState, mapGetters } from 'vuex'; +import { mapActions, mapGetters, mapState } from 'vuex'; import { GlButton, GlLoadingIcon } from '@gitlab/ui'; import { __ } from '~/locale'; import FindFile from '~/vue_shared/components/file_finder/index.vue'; diff --git a/app/assets/javascripts/ide/components/ide_tree.vue b/app/assets/javascripts/ide/components/ide_tree.vue index f93496132a42b7cca9f25fa938a7a012db5bf3e1..598f3a1dac66986854313b08d50511e6a8f40167 100644 --- a/app/assets/javascripts/ide/components/ide_tree.vue +++ b/app/assets/javascripts/ide/components/ide_tree.vue @@ -1,13 +1,11 @@ <script> import { mapState, mapGetters, mapActions } from 'vuex'; -import Icon from '~/vue_shared/components/icon.vue'; import IdeTreeList from './ide_tree_list.vue'; import Upload from './new_dropdown/upload.vue'; import NewEntryButton from './new_dropdown/button.vue'; export default { components: { - Icon, Upload, IdeTreeList, NewEntryButton, diff --git a/app/assets/javascripts/ide/components/ide_tree_list.vue b/app/assets/javascripts/ide/components/ide_tree_list.vue index 3a0dd60f0e0b881978ff83a89a46961c2a545673..bacdfc7c05e39c8a76662a8a258f9dda76111b65 100644 --- a/app/assets/javascripts/ide/components/ide_tree_list.vue +++ b/app/assets/javascripts/ide/components/ide_tree_list.vue @@ -1,14 +1,12 @@ <script> import { mapActions, mapGetters, mapState } from 'vuex'; import { GlSkeletonLoading } from '@gitlab/ui'; -import Icon from '~/vue_shared/components/icon.vue'; import FileRow from '~/vue_shared/components/file_row.vue'; import NavDropdown from './nav_dropdown.vue'; import FileRowExtra from './file_row_extra.vue'; export default { components: { - Icon, GlSkeletonLoading, NavDropdown, FileRow, diff --git a/app/assets/javascripts/ide/components/nav_dropdown.vue b/app/assets/javascripts/ide/components/nav_dropdown.vue index e45d2a62daea61e16cc2511eedb32c21cce682a1..2e290de094346d58fcae6f393e574514924ab8d2 100644 --- a/app/assets/javascripts/ide/components/nav_dropdown.vue +++ b/app/assets/javascripts/ide/components/nav_dropdown.vue @@ -1,12 +1,10 @@ <script> import $ from 'jquery'; -import Icon from '~/vue_shared/components/icon.vue'; import NavForm from './nav_form.vue'; import NavDropdownButton from './nav_dropdown_button.vue'; export default { components: { - Icon, NavDropdownButton, NavForm, }, diff --git a/app/assets/javascripts/ide/components/new_dropdown/modal.vue b/app/assets/javascripts/ide/components/new_dropdown/modal.vue index ecafb4e81c4abd754463b288f706f1672a235f8c..bf3d736ddf32a043878813b7958be1811207f97d 100644 --- a/app/assets/javascripts/ide/components/new_dropdown/modal.vue +++ b/app/assets/javascripts/ide/components/new_dropdown/modal.vue @@ -67,8 +67,8 @@ export default { if (this.entryModal.type === modalTypes.rename) { if (this.entries[this.entryName] && !this.entries[this.entryName].deleted) { flash( - sprintf(s__('The name %{entryName} is already taken in this directory.'), { - entryName: this.entryName, + sprintf(s__('The name "%{name}" is already taken in this directory.'), { + name: this.entryName, }), 'alert', document, @@ -81,22 +81,11 @@ export default { const entryName = parentPath.pop(); parentPath = parentPath.join('/'); - const createPromise = - parentPath && !this.entries[parentPath] - ? this.createTempEntry({ name: parentPath, type: 'tree' }) - : Promise.resolve(); - - createPromise - .then(() => - this.renameEntry({ - path: this.entryModal.entry.path, - name: entryName, - parentPath, - }), - ) - .catch(() => - flash(__('Error creating a new path'), 'alert', document, null, false, true), - ); + this.renameEntry({ + path: this.entryModal.entry.path, + name: entryName, + parentPath, + }); } } else { this.createTempEntry({ diff --git a/app/assets/javascripts/ide/components/new_dropdown/upload.vue b/app/assets/javascripts/ide/components/new_dropdown/upload.vue index 188518dd419dc19ed5bf14917ba528d8f1461a59..e52613086a4eeb9ef2570fcef720dadad6b29464 100644 --- a/app/assets/javascripts/ide/components/new_dropdown/upload.vue +++ b/app/assets/javascripts/ide/components/new_dropdown/upload.vue @@ -1,10 +1,8 @@ <script> -import Icon from '~/vue_shared/components/icon.vue'; import ItemButton from './button.vue'; export default { components: { - Icon, ItemButton, }, props: { diff --git a/app/assets/javascripts/ide/components/panes/collapsible_sidebar.vue b/app/assets/javascripts/ide/components/panes/collapsible_sidebar.vue new file mode 100644 index 0000000000000000000000000000000000000000..d5a123edb807a362f55bd24ecc3384b123939f74 --- /dev/null +++ b/app/assets/javascripts/ide/components/panes/collapsible_sidebar.vue @@ -0,0 +1,151 @@ +<script> +import { mapActions, mapState } from 'vuex'; +import _ from 'underscore'; +import tooltip from '~/vue_shared/directives/tooltip'; +import Icon from '~/vue_shared/components/icon.vue'; +import ResizablePanel from '../resizable_panel.vue'; +import { GlSkeletonLoading } from '@gitlab/ui'; + +export default { + name: 'CollapsibleSidebar', + directives: { + tooltip, + }, + components: { + Icon, + ResizablePanel, + GlSkeletonLoading, + }, + props: { + extensionTabs: { + type: Array, + required: false, + default: () => [], + }, + side: { + type: String, + required: true, + }, + width: { + type: Number, + required: true, + }, + }, + computed: { + ...mapState(['loading']), + ...mapState({ + isOpen(state) { + return state[this.namespace].isOpen; + }, + currentView(state) { + return state[this.namespace].currentView; + }, + isActiveView(state, getters) { + return getters[`${this.namespace}/isActiveView`]; + }, + isAliveView(_state, getters) { + return getters[`${this.namespace}/isAliveView`]; + }, + }), + namespace() { + // eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings + return `${this.side}Pane`; + }, + tabs() { + return this.extensionTabs.filter(tab => tab.show); + }, + tabViews() { + return _.flatten(this.tabs.map(tab => tab.views)); + }, + aliveTabViews() { + return this.tabViews.filter(view => this.isAliveView(view.name)); + }, + otherSide() { + return this.side === 'right' ? 'left' : 'right'; + }, + }, + methods: { + ...mapActions({ + toggleOpen(dispatch) { + return dispatch(`${this.namespace}/toggleOpen`); + }, + open(dispatch, view) { + return dispatch(`${this.namespace}/open`, view); + }, + }), + clickTab(e, tab) { + e.target.blur(); + + if (this.isActiveTab(tab)) { + this.toggleOpen(); + } else { + this.open(tab.views[0]); + } + }, + isActiveTab(tab) { + return tab.views.some(view => this.isActiveView(view.name)); + }, + buttonClasses(tab) { + return [ + this.side === 'right' ? 'is-right' : '', + this.isActiveTab(tab) && this.isOpen ? 'active' : '', + ...(tab.buttonClasses || []), + ]; + }, + }, +}; +</script> + +<template> + <div + :class="`ide-${side}-sidebar`" + :data-qa-selector="`ide_${side}_sidebar`" + class="multi-file-commit-panel ide-sidebar" + > + <resizable-panel + v-show="isOpen" + :collapsible="false" + :initial-width="width" + :min-size="width" + :class="`ide-${side}-sidebar-${currentView}`" + :side="side" + class="multi-file-commit-panel-inner" + > + <div class="h-100 d-flex flex-column align-items-stretch"> + <slot v-if="isOpen" name="header"></slot> + <div + v-for="tabView in aliveTabViews" + v-show="isActiveView(tabView.name)" + :key="tabView.name" + class="flex-fill js-tab-view" + > + <component :is="tabView.component" /> + </div> + <slot name="footer"></slot> + </div> + </resizable-panel> + <nav class="ide-activity-bar"> + <ul class="list-unstyled"> + <li> + <slot name="header-icon"></slot> + </li> + <li v-for="tab of tabs" :key="tab.title"> + <button + v-tooltip + :title="tab.title" + :aria-label="tab.title" + :class="buttonClasses(tab)" + data-container="body" + :data-placement="otherSide" + :data-qa-selector="`${tab.title.toLowerCase()}_tab_button`" + class="ide-sidebar-link" + type="button" + @click="clickTab($event, tab)" + > + <icon :size="16" :name="tab.icon" /> + </button> + </li> + </ul> + </nav> + </div> +</template> diff --git a/app/assets/javascripts/ide/components/panes/right.vue b/app/assets/javascripts/ide/components/panes/right.vue index 200391282e7d3b57f791b0643babb333b36bc257..40ed7d9c4228431aeb72d94c9986afb6dbcf8a31 100644 --- a/app/assets/javascripts/ide/components/panes/right.vue +++ b/app/assets/javascripts/ide/components/panes/right.vue @@ -1,27 +1,17 @@ <script> -import { mapActions, mapState, mapGetters } from 'vuex'; -import _ from 'underscore'; +import { mapGetters, mapState } from 'vuex'; import { __ } from '~/locale'; -import tooltip from '../../../vue_shared/directives/tooltip'; -import Icon from '../../../vue_shared/components/icon.vue'; +import CollapsibleSidebar from './collapsible_sidebar.vue'; import { rightSidebarViews } from '../../constants'; +import MergeRequestInfo from '../merge_requests/info.vue'; import PipelinesList from '../pipelines/list.vue'; import JobsDetail from '../jobs/detail.vue'; -import MergeRequestInfo from '../merge_requests/info.vue'; -import ResizablePanel from '../resizable_panel.vue'; import Clientside from '../preview/clientside.vue'; export default { - directives: { - tooltip, - }, + name: 'RightPane', components: { - Icon, - PipelinesList, - JobsDetail, - ResizablePanel, - MergeRequestInfo, - Clientside, + CollapsibleSidebar, }, props: { extensionTabs: { @@ -32,103 +22,40 @@ export default { }, computed: { ...mapState(['currentMergeRequestId', 'clientsidePreviewEnabled']), - ...mapState('rightPane', ['isOpen', 'currentView']), ...mapGetters(['packageJson']), - ...mapGetters('rightPane', ['isActiveView', 'isAliveView']), showLivePreview() { return this.packageJson && this.clientsidePreviewEnabled; }, - defaultTabs() { + rightExtensionTabs() { return [ { - show: this.currentMergeRequestId, + show: Boolean(this.currentMergeRequestId), title: __('Merge Request'), - views: [rightSidebarViews.mergeRequestInfo], + views: [{ component: MergeRequestInfo, ...rightSidebarViews.mergeRequestInfo }], icon: 'text-description', }, { show: true, title: __('Pipelines'), - views: [rightSidebarViews.pipelines, rightSidebarViews.jobsDetail], + views: [ + { component: PipelinesList, ...rightSidebarViews.pipelines }, + { component: JobsDetail, ...rightSidebarViews.jobsDetail }, + ], icon: 'rocket', }, { show: this.showLivePreview, title: __('Live preview'), - views: [rightSidebarViews.clientSidePreview], + views: [{ component: Clientside, ...rightSidebarViews.clientSidePreview }], icon: 'live-preview', }, + ...this.extensionTabs, ]; }, - tabs() { - return this.defaultTabs.concat(this.extensionTabs).filter(tab => tab.show); - }, - tabViews() { - return _.flatten(this.tabs.map(tab => tab.views)); - }, - aliveTabViews() { - return this.tabViews.filter(view => this.isAliveView(view.name)); - }, - }, - methods: { - ...mapActions('rightPane', ['toggleOpen', 'open']), - clickTab(e, tab) { - e.target.blur(); - - if (this.isActiveTab(tab)) { - this.toggleOpen(); - } else { - this.open(tab.views[0]); - } - }, - isActiveTab(tab) { - return tab.views.some(view => this.isActiveView(view.name)); - }, }, }; </script> <template> - <div class="multi-file-commit-panel ide-right-sidebar" data-qa-selector="ide_right_sidebar"> - <resizable-panel - v-show="isOpen" - :collapsible="false" - :initial-width="350" - :min-size="350" - :class="`ide-right-sidebar-${currentView}`" - side="right" - class="multi-file-commit-panel-inner" - > - <div - v-for="tabView in aliveTabViews" - v-show="isActiveView(tabView.name)" - :key="tabView.name" - class="h-100" - > - <component :is="tabView.component || tabView.name" /> - </div> - </resizable-panel> - <nav class="ide-activity-bar"> - <ul class="list-unstyled"> - <li v-for="tab of tabs" :key="tab.title"> - <button - v-tooltip - :title="tab.title" - :aria-label="tab.title" - :class="{ - active: isActiveTab(tab) && isOpen, - }" - data-container="body" - data-placement="left" - :data-qa-selector="`${tab.title.toLowerCase()}_tab_button`" - class="ide-sidebar-link is-right" - type="button" - @click="clickTab($event, tab)" - > - <icon :size="16" :name="tab.icon" /> - </button> - </li> - </ul> - </nav> - </div> + <collapsible-sidebar :extension-tabs="rightExtensionTabs" side="right" :width="350" /> </template> diff --git a/app/assets/javascripts/ide/components/repo_commit_section.vue b/app/assets/javascripts/ide/components/repo_commit_section.vue index 5201c33b1b4835f30908507e6010b2efbc8c7c6f..b3a7597e7bb630d30302b83e9a438c86bcd0d0fe 100644 --- a/app/assets/javascripts/ide/components/repo_commit_section.vue +++ b/app/assets/javascripts/ide/components/repo_commit_section.vue @@ -1,7 +1,6 @@ <script> import { mapState, mapActions, mapGetters } from 'vuex'; import tooltip from '~/vue_shared/directives/tooltip'; -import Icon from '~/vue_shared/components/icon.vue'; import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue'; import CommitFilesList from './commit_sidebar/list.vue'; import EmptyState from './commit_sidebar/empty_state.vue'; @@ -11,7 +10,6 @@ import { activityBarViews, stageKeys } from '../constants'; export default { components: { DeprecatedModal, - Icon, CommitFilesList, EmptyState, }, @@ -96,7 +94,6 @@ export default { :empty-state-text="__('There are no unstaged changes')" action="stageAllChanges" action-btn-icon="stage-all" - item-action-component="stage-button" class="is-first" icon-name="unstaged" /> @@ -110,7 +107,6 @@ export default { :empty-state-text="__('There are no staged changes')" action="unstageAllChanges" action-btn-icon="unstage-all" - item-action-component="unstage-button" icon-name="staged" /> </template> diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue index 08b3e8a34d69d8b730ba76b16c975d887f4b4ad6..7e2ab96d1de623262e902ca80010337d296796b6 100644 --- a/app/assets/javascripts/ide/components/repo_editor.vue +++ b/app/assets/javascripts/ide/components/repo_editor.vue @@ -32,7 +32,13 @@ export default { ...mapState('rightPane', { rightPaneIsOpen: 'isOpen', }), - ...mapState(['rightPanelCollapsed', 'viewer', 'panelResizing', 'currentActivityView']), + ...mapState([ + 'rightPanelCollapsed', + 'viewer', + 'panelResizing', + 'currentActivityView', + 'renderWhitespaceInCode', + ]), ...mapGetters([ 'currentMergeRequest', 'getStagedFile', @@ -76,6 +82,11 @@ export default { showEditor() { return !this.shouldHideEditor && this.isEditorViewMode; }, + editorOptions() { + return { + renderWhitespace: this.renderWhitespaceInCode ? 'all' : 'none', + }; + }, }, watch: { file(newVal, oldVal) { @@ -131,7 +142,7 @@ export default { }, mounted() { if (!this.editor) { - this.editor = Editor.create(); + this.editor = Editor.create(this.editorOptions); } this.initEditor(); }, diff --git a/app/assets/javascripts/ide/components/repo_tabs.vue b/app/assets/javascripts/ide/components/repo_tabs.vue index 4dbc4383894ac8e65d2fdc8238ceaa1b8311a080..1b7f149097b7e79e57eff81d2d431f38a76fd7f2 100644 --- a/app/assets/javascripts/ide/components/repo_tabs.vue +++ b/app/assets/javascripts/ide/components/repo_tabs.vue @@ -1,13 +1,11 @@ <script> import { mapActions } from 'vuex'; import RepoTab from './repo_tab.vue'; -import EditorMode from './editor_mode_dropdown.vue'; import router from '../ide_router'; export default { components: { RepoTab, - EditorMode, }, props: { activeFile: { diff --git a/app/assets/javascripts/ide/index.js b/app/assets/javascripts/ide/index.js index cdfebd19fa4878d944492227e2380c2966dc4d13..4c4166e11f53041cd7a91bfe33c22c87970d96f1 100644 --- a/app/assets/javascripts/ide/index.js +++ b/app/assets/javascripts/ide/index.js @@ -50,6 +50,7 @@ export function initIde(el, options = {}) { }); this.setInitialData({ clientsidePreviewEnabled: parseBoolean(el.dataset.clientsidePreviewEnabled), + renderWhitespaceInCode: parseBoolean(el.dataset.renderWhitespaceInCode), }); }, methods: { diff --git a/app/assets/javascripts/ide/lib/editor.js b/app/assets/javascripts/ide/lib/editor.js index 02038fcb534593fa0a97ee4cf81c13c62d7b5989..d1056ea6b986e918c287af9d5946b6c5e03c4e48 100644 --- a/app/assets/javascripts/ide/lib/editor.js +++ b/app/assets/javascripts/ide/lib/editor.js @@ -23,20 +23,24 @@ export const clearDomElement = el => { }; export default class Editor { - static create() { + static create(options = {}) { if (!this.editorInstance) { - this.editorInstance = new Editor(); + this.editorInstance = new Editor(options); } return this.editorInstance; } - constructor() { + constructor(options = {}) { this.currentModel = null; this.instance = null; this.dirtyDiffController = null; this.disposable = new Disposable(); this.modelManager = new ModelManager(); this.decorationsController = new DecorationsController(this); + this.options = { + ...defaultEditorOptions, + ...options, + }; setupMonacoTheme(); @@ -51,7 +55,7 @@ export default class Editor { this.disposable.add( (this.instance = monacoEditor.create(domElement, { - ...defaultEditorOptions, + ...this.options, })), (this.dirtyDiffController = new DirtyDiffController( this.modelManager, @@ -71,7 +75,7 @@ export default class Editor { this.disposable.add( (this.instance = monacoEditor.createDiffEditor(domElement, { - ...defaultEditorOptions, + ...this.options, quickSuggestions: false, occurrencesHighlight: false, renderSideBySide: Editor.renderSideBySide(domElement), diff --git a/app/assets/javascripts/ide/stores/actions.js b/app/assets/javascripts/ide/stores/actions.js index dd69e2d6f1f37d74468022d254ec84c04331b2cc..34e7cc304ddca7f7ad60feed34e339b084b65d56 100644 --- a/app/assets/javascripts/ide/stores/actions.js +++ b/app/assets/javascripts/ide/stores/actions.js @@ -16,21 +16,7 @@ export const redirectToUrl = (self, url) => visitUrl(url); export const setInitialData = ({ commit }, data) => commit(types.SET_INITIAL_DATA, data); export const discardAllChanges = ({ state, commit, dispatch }) => { - state.changedFiles.forEach(file => { - if (file.tempFile || file.prevPath) dispatch('closeFile', file); - - if (file.tempFile) { - dispatch('deleteEntry', file.path); - } else if (file.prevPath) { - dispatch('renameEntry', { - path: file.path, - name: file.prevName, - parentPath: file.prevParentPath, - }); - } else { - commit(types.DISCARD_FILE_CHANGES, file.path); - } - }); + state.changedFiles.forEach(file => dispatch('restoreOriginalFile', file.path)); commit(types.REMOVE_ALL_CHANGES_FILES); }; @@ -47,79 +33,66 @@ export const setPanelCollapsedStatus = ({ commit }, { side, collapsed }) => { } }; -export const toggleRightPanelCollapsed = ({ dispatch, state }, e = undefined) => { - if (e) { - $(e.currentTarget) - .tooltip('hide') - .blur(); - } - - dispatch('setPanelCollapsedStatus', { - side: 'right', - collapsed: !state.rightPanelCollapsed, - }); -}; - export const setResizingStatus = ({ commit }, resizing) => { commit(types.SET_RESIZING_STATUS, resizing); }; export const createTempEntry = ( - { state, commit, dispatch }, + { state, commit, dispatch, getters }, { name, type, content = '', base64 = false, binary = false, rawPath = '' }, -) => - new Promise(resolve => { - const fullName = name.slice(-1) !== '/' && type === 'tree' ? `${name}/` : name; - - if (state.entries[name] && !state.entries[name].deleted) { - flash( - `The name "${name.split('/').pop()}" is already taken in this directory.`, - 'alert', - document, - null, - false, - true, - ); - - resolve(); - - return null; - } +) => { + const fullName = name.slice(-1) !== '/' && type === 'tree' ? `${name}/` : name; + + if (state.entries[name] && !state.entries[name].deleted) { + flash( + sprintf(__('The name "%{name}" is already taken in this directory.'), { + name: name.split('/').pop(), + }), + 'alert', + document, + null, + false, + true, + ); - const data = decorateFiles({ - data: [fullName], - projectId: state.currentProjectId, - branchId: state.currentBranchId, - type, - tempFile: true, - content, - base64, - binary, - rawPath, - }); - const { file, parentPath } = data; + return; + } - commit(types.CREATE_TMP_ENTRY, { - data, - projectId: state.currentProjectId, - branchId: state.currentBranchId, - }); + const data = decorateFiles({ + data: [fullName], + projectId: state.currentProjectId, + branchId: state.currentBranchId, + type, + tempFile: true, + content, + base64, + binary, + rawPath, + }); + const { file, parentPath } = data; - if (type === 'blob') { - commit(types.TOGGLE_FILE_OPEN, file.path); - commit(types.ADD_FILE_TO_CHANGED, file.path); - dispatch('setFileActive', file.path); - dispatch('triggerFilesChange'); - } + commit(types.CREATE_TMP_ENTRY, { + data, + projectId: state.currentProjectId, + branchId: state.currentBranchId, + }); - if (parentPath && !state.entries[parentPath].opened) { - commit(types.TOGGLE_TREE_OPEN, parentPath); - } + if (type === 'blob') { + commit(types.TOGGLE_FILE_OPEN, file.path); - resolve(file); + if (gon.features?.stageAllByDefault) + commit(types.STAGE_CHANGE, { path: file.path, diffInfo: getters.getDiffInfo(file.path) }); + else commit(types.ADD_FILE_TO_CHANGED, file.path); - return null; - }); + dispatch('setFileActive', file.path); + dispatch('triggerFilesChange'); + dispatch('burstUnusedSeal'); + } + + if (parentPath && !state.entries[parentPath].opened) { + commit(types.TOGGLE_TREE_OPEN, parentPath); + } +}; export const scrollToTab = () => { Vue.nextTick(() => { @@ -133,28 +106,40 @@ export const scrollToTab = () => { }); }; -export const stageAllChanges = ({ state, commit, dispatch }) => { +export const stageAllChanges = ({ state, commit, dispatch, getters }) => { const openFile = state.openFiles[0]; commit(types.SET_LAST_COMMIT_MSG, ''); - state.changedFiles.forEach(file => commit(types.STAGE_CHANGE, file.path)); + state.changedFiles.forEach(file => + commit(types.STAGE_CHANGE, { path: file.path, diffInfo: getters.getDiffInfo(file.path) }), + ); - dispatch('openPendingTab', { - file: state.stagedFiles.find(f => f.path === openFile.path), - keyPrefix: stageKeys.staged, - }); + const file = getters.getStagedFile(openFile.path); + + if (file) { + dispatch('openPendingTab', { + file, + keyPrefix: stageKeys.staged, + }); + } }; -export const unstageAllChanges = ({ state, commit, dispatch }) => { +export const unstageAllChanges = ({ state, commit, dispatch, getters }) => { const openFile = state.openFiles[0]; - state.stagedFiles.forEach(file => commit(types.UNSTAGE_CHANGE, file.path)); + state.stagedFiles.forEach(file => + commit(types.UNSTAGE_CHANGE, { path: file.path, diffInfo: getters.getDiffInfo(file.path) }), + ); - dispatch('openPendingTab', { - file: state.changedFiles.find(f => f.path === openFile.path), - keyPrefix: stageKeys.unstaged, - }); + const file = getters.getChangedFile(openFile.path); + + if (file) { + dispatch('openPendingTab', { + file, + keyPrefix: stageKeys.unstaged, + }); + } }; export const updateViewer = ({ commit }, viewer) => { @@ -212,8 +197,9 @@ export const deleteEntry = ({ commit, dispatch, state }, path) => { const entry = state.entries[path]; const { prevPath, prevName, prevParentPath } = entry; const isTree = entry.type === 'tree'; + const prevEntry = prevPath && state.entries[prevPath]; - if (prevPath) { + if (prevPath && (!prevEntry || prevEntry.deleted)) { dispatch('renameEntry', { path, name: prevName, @@ -222,7 +208,9 @@ export const deleteEntry = ({ commit, dispatch, state }, path) => { dispatch('deleteEntry', prevPath); return; } - if (state.unusedSeal) dispatch('burstUnusedSeal'); + + dispatch('burstUnusedSeal'); + if (entry.opened) dispatch('closeFile', entry); if (isTree) { @@ -241,9 +229,14 @@ export const deleteEntry = ({ commit, dispatch, state }, path) => { export const resetOpenFiles = ({ commit }) => commit(types.RESET_OPEN_FILES); -export const renameEntry = ({ dispatch, commit, state }, { path, name, parentPath }) => { +export const renameEntry = ({ dispatch, commit, state, getters }, { path, name, parentPath }) => { const entry = state.entries[path]; const newPath = parentPath ? `${parentPath}/${name}` : name; + const existingParent = parentPath && state.entries[parentPath]; + + if (parentPath && (!existingParent || existingParent.deleted)) { + dispatch('createTempEntry', { name: parentPath, type: 'tree' }); + } commit(types.RENAME_ENTRY, { path, name, parentPath }); @@ -266,7 +259,11 @@ export const renameEntry = ({ dispatch, commit, state }, { path, name, parentPat if (isReset) { commit(types.REMOVE_FILE_FROM_STAGED_AND_CHANGED, newEntry); } else if (!isInChanges) { - commit(types.ADD_FILE_TO_CHANGED, newPath); + if (gon.features?.stageAllByDefault) + commit(types.STAGE_CHANGE, { path: newPath, diffInfo: getters.getDiffInfo(newPath) }); + else commit(types.ADD_FILE_TO_CHANGED, newPath); + + dispatch('burstUnusedSeal'); } if (!newEntry.tempFile) { diff --git a/app/assets/javascripts/ide/stores/actions/file.js b/app/assets/javascripts/ide/stores/actions/file.js index 8864224c19e627b11b3cff42bdaeb23e0fb5ac70..70a966afa66cfd814fcf48b2f1502f2dabc73c7a 100644 --- a/app/assets/javascripts/ide/stores/actions/file.js +++ b/app/assets/javascripts/ide/stores/actions/file.js @@ -61,8 +61,10 @@ export const getFileData = ( { path, makeFileActive = true, openFile = makeFileActive }, ) => { const file = state.entries[path]; + const fileDeletedAndReadded = getters.isFileDeletedAndReadded(path); - if (file.raw || (file.tempFile && !file.prevPath)) return Promise.resolve(); + if (file.raw || (file.tempFile && !file.prevPath && !fileDeletedAndReadded)) + return Promise.resolve(); commit(types.TOGGLE_LOADING, { entry: file }); @@ -102,11 +104,16 @@ export const setFileMrChange = ({ commit }, { file, mrChange }) => { export const getRawFileData = ({ state, commit, dispatch, getters }, { path }) => { const file = state.entries[path]; + const stagedFile = state.stagedFiles.find(f => f.path === path); + return new Promise((resolve, reject) => { + const fileDeletedAndReadded = getters.isFileDeletedAndReadded(path); service - .getRawFileData(file) + .getRawFileData(fileDeletedAndReadded ? stagedFile : file) .then(raw => { - if (!(file.tempFile && !file.prevPath)) commit(types.SET_FILE_RAW_DATA, { file, raw }); + if (!(file.tempFile && !file.prevPath && !fileDeletedAndReadded)) + commit(types.SET_FILE_RAW_DATA, { file, raw, fileDeletedAndReadded }); + if (file.mrChange && file.mrChange.new_file === false) { const baseSha = (getters.currentMergeRequest && getters.currentMergeRequest.baseCommitSha) || ''; @@ -140,7 +147,7 @@ export const getRawFileData = ({ state, commit, dispatch, getters }, { path }) = }); }; -export const changeFileContent = ({ commit, dispatch, state }, { path, content }) => { +export const changeFileContent = ({ commit, dispatch, state, getters }, { path, content }) => { const file = state.entries[path]; commit(types.UPDATE_FILE_CONTENT, { path, @@ -150,8 +157,10 @@ export const changeFileContent = ({ commit, dispatch, state }, { path, content } const indexOfChangedFile = state.changedFiles.findIndex(f => f.path === path); if (file.changed && indexOfChangedFile === -1) { - commit(types.ADD_FILE_TO_CHANGED, path); - } else if (!file.changed && indexOfChangedFile !== -1) { + if (gon.features?.stageAllByDefault) + commit(types.STAGE_CHANGE, { path, diffInfo: getters.getDiffInfo(path) }); + else commit(types.ADD_FILE_TO_CHANGED, path); + } else if (!file.changed && !file.tempFile && indexOfChangedFile !== -1) { commit(types.REMOVE_FILE_FROM_CHANGED, path); } @@ -184,23 +193,40 @@ export const setFileViewMode = ({ commit }, { file, viewMode }) => { commit(types.SET_FILE_VIEWMODE, { file, viewMode }); }; -export const discardFileChanges = ({ dispatch, state, commit, getters }, path) => { +export const restoreOriginalFile = ({ dispatch, state, commit }, path) => { const file = state.entries[path]; + const isDestructiveDiscard = file.tempFile || file.prevPath; if (file.deleted && file.parentPath) { dispatch('restoreTree', file.parentPath); } - commit(types.DISCARD_FILE_CHANGES, path); - commit(types.REMOVE_FILE_FROM_CHANGED, path); + if (isDestructiveDiscard) { + dispatch('closeFile', file); + } + + if (file.tempFile) { + dispatch('deleteEntry', file.path); + } else { + commit(types.DISCARD_FILE_CHANGES, file.path); + } if (file.prevPath) { - dispatch('discardFileChanges', file.prevPath); + dispatch('renameEntry', { + path: file.path, + name: file.prevName, + parentPath: file.prevParentPath, + }); } +}; - if (file.tempFile && file.opened) { - commit(types.TOGGLE_FILE_OPEN, path); - } else if (getters.activeFile && file.path === getters.activeFile.path) { +export const discardFileChanges = ({ dispatch, state, commit, getters }, path) => { + const file = state.entries[path]; + const isDestructiveDiscard = file.tempFile || file.prevPath; + + dispatch('restoreOriginalFile', path); + + if (!isDestructiveDiscard && file.path === getters.activeFile?.path) { dispatch('updateDelayViewerUpdated', true) .then(() => { router.push(`/project${file.url}`); @@ -210,24 +236,26 @@ export const discardFileChanges = ({ dispatch, state, commit, getters }, path) = }); } + commit(types.REMOVE_FILE_FROM_CHANGED, path); + eventHub.$emit(`editor.update.model.new.content.${file.key}`, file.content); eventHub.$emit(`editor.update.model.dispose.unstaged-${file.key}`, file.content); }; -export const stageChange = ({ commit, state, dispatch }, path) => { - const stagedFile = state.stagedFiles.find(f => f.path === path); - const openFile = state.openFiles.find(f => f.path === path); +export const stageChange = ({ commit, dispatch, getters }, path) => { + const stagedFile = getters.getStagedFile(path); + const openFile = getters.getOpenFile(path); - commit(types.STAGE_CHANGE, path); + commit(types.STAGE_CHANGE, { path, diffInfo: getters.getDiffInfo(path) }); commit(types.SET_LAST_COMMIT_MSG, ''); if (stagedFile) { eventHub.$emit(`editor.update.model.new.content.staged-${stagedFile.key}`, stagedFile.content); } - if (openFile && openFile.active) { - const file = state.stagedFiles.find(f => f.path === path); + const file = getters.getStagedFile(path); + if (openFile && openFile.active && file) { dispatch('openPendingTab', { file, keyPrefix: stageKeys.staged, @@ -235,14 +263,14 @@ export const stageChange = ({ commit, state, dispatch }, path) => { } }; -export const unstageChange = ({ commit, dispatch, state }, path) => { - const openFile = state.openFiles.find(f => f.path === path); +export const unstageChange = ({ commit, dispatch, getters }, path) => { + const openFile = getters.getOpenFile(path); - commit(types.UNSTAGE_CHANGE, path); + commit(types.UNSTAGE_CHANGE, { path, diffInfo: getters.getDiffInfo(path) }); - if (openFile && openFile.active) { - const file = state.changedFiles.find(f => f.path === path); + const file = getters.getChangedFile(path); + if (openFile && openFile.active && file) { dispatch('openPendingTab', { file, keyPrefix: stageKeys.unstaged, diff --git a/app/assets/javascripts/ide/stores/actions/merge_request.js b/app/assets/javascripts/ide/stores/actions/merge_request.js index 6790c0fbdaa62a5b508411fa4eca8e95a2341a97..806ec38430ce525e5e4ad8da2dfb989d7518dada 100644 --- a/app/assets/javascripts/ide/stores/actions/merge_request.js +++ b/app/assets/javascripts/ide/stores/actions/merge_request.js @@ -141,7 +141,7 @@ export const getMergeRequestVersions = ( }); export const openMergeRequest = ( - { dispatch, state }, + { dispatch, state, getters }, { projectId, targetProjectId, mergeRequestId } = {}, ) => dispatch('getMergeRequestData', { @@ -152,17 +152,18 @@ export const openMergeRequest = ( .then(mr => { dispatch('setCurrentBranchId', mr.source_branch); - // getFiles needs to be called after getting the branch data - // since files are fetched using the last commit sha of the branch return dispatch('getBranchData', { projectId, branchId: mr.source_branch, - }).then(() => - dispatch('getFiles', { + }).then(() => { + const branch = getters.findBranch(projectId, mr.source_branch); + + return dispatch('getFiles', { projectId, branchId: mr.source_branch, - }), - ); + ref: branch.commit.id, + }); + }); }) .then(() => dispatch('getMergeRequestVersions', { diff --git a/app/assets/javascripts/ide/stores/actions/project.js b/app/assets/javascripts/ide/stores/actions/project.js index 20887e7d0ac7dd3eed692784bd6224c595eade2f..e206f9bee9ef7c52f89d3a16c5f6df3986e08285 100644 --- a/app/assets/javascripts/ide/stores/actions/project.js +++ b/app/assets/javascripts/ide/stores/actions/project.js @@ -83,8 +83,11 @@ export const showBranchNotFoundError = ({ dispatch }, branchId) => { }); }; -export const showEmptyState = ({ commit, state }, { projectId, branchId }) => { +export const showEmptyState = ({ commit, state, dispatch }, { projectId, branchId }) => { const treePath = `${projectId}/${branchId}`; + + dispatch('setCurrentBranchId', branchId); + commit(types.CREATE_TREE, { treePath }); commit(types.TOGGLE_LOADING, { entry: state.trees[treePath], @@ -111,7 +114,7 @@ export const loadFile = ({ dispatch, state }, { basePath }) => { } }; -export const loadBranch = ({ dispatch }, { projectId, branchId }) => +export const loadBranch = ({ dispatch, getters }, { projectId, branchId }) => dispatch('getBranchData', { projectId, branchId, @@ -121,9 +124,13 @@ export const loadBranch = ({ dispatch }, { projectId, branchId }) => projectId, branchId, }); + + const branch = getters.findBranch(projectId, branchId); + return dispatch('getFiles', { projectId, branchId, + ref: branch.commit.id, }); }) .catch(() => { diff --git a/app/assets/javascripts/ide/stores/actions/tree.js b/app/assets/javascripts/ide/stores/actions/tree.js index 72cd099c5a53d2581f65383e016c109063b383e1..ba85194b9100a1a8c9a2b73aed0934df9cfb306a 100644 --- a/app/assets/javascripts/ide/stores/actions/tree.js +++ b/app/assets/javascripts/ide/stores/actions/tree.js @@ -46,19 +46,20 @@ export const setDirectoryData = ({ state, commit }, { projectId, branchId, treeL }); }; -export const getFiles = ({ state, commit, dispatch, getters }, { projectId, branchId } = {}) => +export const getFiles = ({ state, commit, dispatch }, payload = {}) => new Promise((resolve, reject) => { + const { projectId, branchId, ref = branchId } = payload; + if ( !state.trees[`${projectId}/${branchId}`] || (state.trees[`${projectId}/${branchId}`].tree && state.trees[`${projectId}/${branchId}`].tree.length === 0) ) { const selectedProject = state.projects[projectId]; - const selectedBranch = getters.findBranch(projectId, branchId); commit(types.CREATE_TREE, { treePath: `${projectId}/${branchId}` }); service - .getFiles(selectedProject.web_url, selectedBranch.commit.id) + .getFiles(selectedProject.web_url, ref) .then(({ data }) => { const { entries, treeList } = decorateFiles({ data, @@ -77,8 +78,8 @@ export const getFiles = ({ state, commit, dispatch, getters }, { projectId, bran .catch(e => { dispatch('setErrorMessage', { text: __('An error occurred whilst loading all the files.'), - action: payload => - dispatch('getFiles', payload).then(() => dispatch('setErrorMessage', null)), + action: actionPayload => + dispatch('getFiles', actionPayload).then(() => dispatch('setErrorMessage', null)), actionText: __('Please try again'), actionPayload: { projectId, branchId }, }); diff --git a/app/assets/javascripts/ide/stores/getters.js b/app/assets/javascripts/ide/stores/getters.js index bb8374b4e78151a7ab8c657bd82fcd9a8fe47b85..2fc574cd343493414680e78c8faf14ba01784e09 100644 --- a/app/assets/javascripts/ide/stores/getters.js +++ b/app/assets/javascripts/ide/stores/getters.js @@ -64,6 +64,7 @@ export const allBlobs = state => export const getChangedFile = state => path => state.changedFiles.find(f => f.path === path); export const getStagedFile = state => path => state.stagedFiles.find(f => f.path === path); +export const getOpenFile = state => path => state.openFiles.find(f => f.path === path); export const lastOpenedFile = state => [...state.changedFiles, ...state.stagedFiles].sort((a, b) => b.lastOpenedAt - a.lastOpenedAt)[0]; diff --git a/app/assets/javascripts/ide/stores/modules/pane/actions.js b/app/assets/javascripts/ide/stores/modules/pane/actions.js index 7f5d167a14fc260ce09dc0e8e91367c5891865ca..a8fcdf539ec46f1c962cf46fe7f0ca875313bfa8 100644 --- a/app/assets/javascripts/ide/stores/modules/pane/actions.js +++ b/app/assets/javascripts/ide/stores/modules/pane/actions.js @@ -1,17 +1,17 @@ import * as types from './mutation_types'; -export const toggleOpen = ({ dispatch, state }, view) => { +export const toggleOpen = ({ dispatch, state }) => { if (state.isOpen) { dispatch('close'); } else { - dispatch('open', view); + dispatch('open'); } }; -export const open = ({ commit }, view) => { +export const open = ({ state, commit }, view) => { commit(types.SET_OPEN, true); - if (view) { + if (view && view.name !== state.currentView) { const { name, keepAlive } = view; commit(types.SET_CURRENT_VIEW, name); diff --git a/app/assets/javascripts/ide/stores/mutation_types.js b/app/assets/javascripts/ide/stores/mutation_types.js index f0b4718d02544d17cf59dc67be0525d43a4e094e..4dde53a9fdff1e16d0b6e577af977ba916a6caf7 100644 --- a/app/assets/javascripts/ide/stores/mutation_types.js +++ b/app/assets/javascripts/ide/stores/mutation_types.js @@ -11,7 +11,6 @@ export const SET_LINKS = 'SET_LINKS'; // Project Mutation Types export const SET_PROJECT = 'SET_PROJECT'; export const SET_CURRENT_PROJECT = 'SET_CURRENT_PROJECT'; -export const TOGGLE_PROJECT_OPEN = 'TOGGLE_PROJECT_OPEN'; export const TOGGLE_EMPTY_STATE = 'TOGGLE_EMPTY_STATE'; // Merge Request Mutation Types diff --git a/app/assets/javascripts/ide/stores/mutations/file.js b/app/assets/javascripts/ide/stores/mutations/file.js index 8caeb2d73b21b056bd0a88fa60b3746fb4604aaf..313fa1fe029ba501bb2767d5a094ffbd9fc66385 100644 --- a/app/assets/javascripts/ide/stores/mutations/file.js +++ b/app/assets/javascripts/ide/stores/mutations/file.js @@ -54,27 +54,29 @@ export default { } }); }, - [types.SET_FILE_RAW_DATA](state, { file, raw }) { + [types.SET_FILE_RAW_DATA](state, { file, raw, fileDeletedAndReadded = false }) { const openPendingFile = state.openFiles.find( - f => f.path === file.path && f.pending && !(f.tempFile && !f.prevPath), + f => + f.path === file.path && f.pending && !(f.tempFile && !f.prevPath && !fileDeletedAndReadded), ); + const stagedFile = state.stagedFiles.find(f => f.path === file.path); - if (file.tempFile && file.content === '') { - Object.assign(state.entries[file.path], { - content: raw, - }); + if (file.tempFile && file.content === '' && !fileDeletedAndReadded) { + Object.assign(state.entries[file.path], { content: raw }); + } else if (fileDeletedAndReadded) { + Object.assign(stagedFile, { raw }); } else { - Object.assign(state.entries[file.path], { - raw, - }); + Object.assign(state.entries[file.path], { raw }); } if (!openPendingFile) return; if (!openPendingFile.tempFile) { openPendingFile.raw = raw; - } else if (openPendingFile.tempFile) { + } else if (openPendingFile.tempFile && !fileDeletedAndReadded) { openPendingFile.content = raw; + } else if (fileDeletedAndReadded) { + Object.assign(stagedFile, { raw }); } }, [types.SET_FILE_BASE_RAW_DATA](state, { file, baseRaw }) { @@ -132,7 +134,7 @@ export default { [types.DISCARD_FILE_CHANGES](state, path) { const stagedFile = state.stagedFiles.find(f => f.path === path); const entry = state.entries[path]; - const { deleted, prevPath } = entry; + const { deleted } = entry; Object.assign(state.entries[path], { content: stagedFile ? stagedFile.content : state.entries[path].raw, @@ -146,12 +148,6 @@ export default { : state.trees[`${state.currentProjectId}/${state.currentBranchId}`]; parent.tree = sortTree(parent.tree.concat(entry)); - } else if (prevPath) { - const parent = entry.parentPath - ? state.entries[entry.parentPath] - : state.trees[`${state.currentProjectId}/${state.currentBranchId}`]; - - parent.tree = parent.tree.filter(f => f.path !== path); } }, [types.ADD_FILE_TO_CHANGED](state, path) { @@ -164,31 +160,32 @@ export default { changedFiles: state.changedFiles.filter(f => f.path !== path), }); }, - [types.STAGE_CHANGE](state, path) { + [types.STAGE_CHANGE](state, { path, diffInfo }) { const stagedFile = state.stagedFiles.find(f => f.path === path); Object.assign(state, { changedFiles: state.changedFiles.filter(f => f.path !== path), entries: Object.assign(state.entries, { [path]: Object.assign(state.entries[path], { - staged: true, + staged: diffInfo.exists, + changed: diffInfo.changed, + tempFile: diffInfo.tempFile, + deleted: diffInfo.deleted, }), }), }); if (stagedFile) { - Object.assign(stagedFile, { - ...state.entries[path], - }); + Object.assign(stagedFile, { ...state.entries[path] }); } else { - Object.assign(state, { - stagedFiles: state.stagedFiles.concat({ - ...state.entries[path], - }), - }); + state.stagedFiles = [...state.stagedFiles, { ...state.entries[path] }]; + } + + if (!diffInfo.exists) { + state.stagedFiles = state.stagedFiles.filter(f => f.path !== path); } }, - [types.UNSTAGE_CHANGE](state, path) { + [types.UNSTAGE_CHANGE](state, { path, diffInfo }) { const changedFile = state.changedFiles.find(f => f.path === path); const stagedFile = state.stagedFiles.find(f => f.path === path); @@ -201,9 +198,11 @@ export default { changed: true, }); - Object.assign(state, { - changedFiles: state.changedFiles.concat(state.entries[path]), - }); + state.changedFiles = state.changedFiles.concat(state.entries[path]); + } + + if (!diffInfo.exists) { + state.changedFiles = state.changedFiles.filter(f => f.path !== path); } Object.assign(state, { @@ -211,6 +210,9 @@ export default { entries: Object.assign(state.entries, { [path]: Object.assign(state.entries[path], { staged: false, + changed: diffInfo.changed, + tempFile: diffInfo.tempFile, + deleted: diffInfo.deleted, }), }), }); diff --git a/app/assets/javascripts/ide/stores/state.js b/app/assets/javascripts/ide/stores/state.js index d400b9831a99939e53c1cb45e754e3203d0ee433..6488389977cc6130aa36d6fb63ec2aea530991f5 100644 --- a/app/assets/javascripts/ide/stores/state.js +++ b/app/assets/javascripts/ide/stores/state.js @@ -31,4 +31,5 @@ export default () => ({ entry: {}, }, clientsidePreviewEnabled: false, + renderWhitespaceInCode: false, }); diff --git a/app/assets/javascripts/import_projects/components/provider_repo_table_row.vue b/app/assets/javascripts/import_projects/components/provider_repo_table_row.vue index 3c6c9c71b8c12367e8c1dc3ad13fdf95ad910adb..6e227ab3d82c9130850fb91ac1b40f14e9a49c70 100644 --- a/app/assets/javascripts/import_projects/components/provider_repo_table_row.vue +++ b/app/assets/javascripts/import_projects/components/provider_repo_table_row.vue @@ -2,7 +2,6 @@ import { mapState, mapGetters, mapActions } from 'vuex'; import Select2Select from '~/vue_shared/components/select2_select.vue'; import { __ } from '~/locale'; -import LoadingButton from '~/vue_shared/components/loading_button.vue'; import eventHub from '../event_hub'; import { STATUSES } from '../constants'; import ImportStatus from './import_status.vue'; @@ -11,7 +10,6 @@ export default { name: 'ProviderRepoTableRow', components: { Select2Select, - LoadingButton, ImportStatus, }, props: { diff --git a/app/assets/javascripts/issuable_context.js b/app/assets/javascripts/issuable_context.js index 48e7ed1318d081b27505a0a156061fb7e4a93a70..566efa0d7d63ac66e94a9dc7cc2fcc6d36213959 100644 --- a/app/assets/javascripts/issuable_context.js +++ b/app/assets/javascripts/issuable_context.js @@ -1,6 +1,6 @@ import $ from 'jquery'; import Cookies from 'js-cookie'; -import bp from './breakpoints'; +import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import UsersSelect from './users_select'; export default class IssuableContext { @@ -48,7 +48,9 @@ export default class IssuableContext { window.addEventListener('beforeunload', () => { // collapsed_gutter cookie hides the sidebar const bpBreakpoint = bp.getBreakpointSize(); - if (bpBreakpoint === 'xs' || bpBreakpoint === 'sm') { + const supportedSizes = ['xs', 'sm', 'md']; + + if (supportedSizes.includes(bpBreakpoint)) { Cookies.set('collapsed_gutter', true); } }); diff --git a/app/assets/javascripts/issuable_form.js b/app/assets/javascripts/issuable_form.js index 1d0807dc15dfae3c3be55b1d15167d5a89f678a4..cf780556c8de9005aa1fadea0451b4decb50b81f 100644 --- a/app/assets/javascripts/issuable_form.js +++ b/app/assets/javascripts/issuable_form.js @@ -8,19 +8,23 @@ import AutoWidthDropdownSelect from './issuable/auto_width_dropdown_select'; import { parsePikadayDate, pikadayToString } from './lib/utils/datetime_utility'; import { queryToObject, objectToQuery } from './lib/utils/url_utility'; +const MR_SOURCE_BRANCH = 'merge_request[source_branch]'; +const MR_TARGET_BRANCH = 'merge_request[target_branch]'; + function organizeQuery(obj, isFallbackKey = false) { - const sourceBranch = 'merge_request[source_branch]'; - const targetBranch = 'merge_request[target_branch]'; + if (!obj[MR_SOURCE_BRANCH] && !obj[MR_TARGET_BRANCH]) { + return obj; + } if (isFallbackKey) { return { - [sourceBranch]: obj[sourceBranch], + [MR_SOURCE_BRANCH]: obj[MR_SOURCE_BRANCH], }; } return { - [sourceBranch]: obj[sourceBranch], - [targetBranch]: obj[targetBranch], + [MR_SOURCE_BRANCH]: obj[MR_SOURCE_BRANCH], + [MR_TARGET_BRANCH]: obj[MR_TARGET_BRANCH], }; } @@ -87,7 +91,8 @@ export default class IssuableForm { } initAutosave() { - const searchTerm = format(document.location.search); + const { search } = document.location; + const searchTerm = format(search); const fallbackKey = getFallbackKey(); this.autosave = new Autosave( diff --git a/app/assets/javascripts/jobs/components/job_app.vue b/app/assets/javascripts/jobs/components/job_app.vue index 859f839741f062d681e8d9215d826c4292e33579..809b3d5f57eb9ceee377ce179e790677610eeb65 100644 --- a/app/assets/javascripts/jobs/components/job_app.vue +++ b/app/assets/javascripts/jobs/components/job_app.vue @@ -2,9 +2,9 @@ import _ from 'underscore'; import { mapGetters, mapState, mapActions } from 'vuex'; import { GlLoadingIcon } from '@gitlab/ui'; +import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import { isScrolledToBottom } from '~/lib/utils/scroll_utils'; import { polyfillSticky } from '~/lib/utils/sticky'; -import bp from '~/breakpoints'; import CiHeader from '~/vue_shared/components/header_ci_component.vue'; import Callout from '~/vue_shared/components/callout.vue'; import Icon from '~/vue_shared/components/icon.vue'; @@ -200,7 +200,8 @@ export default { this.updateScroll(); }, updateSidebar() { - if (bp.getBreakpointSize() === 'xs') { + const breakpoint = bp.getBreakpointSize(); + if (breakpoint === 'xs' || breakpoint === 'sm') { this.hideSidebar(); } else if (!this.isSidebarOpen) { this.showSidebar(); diff --git a/app/assets/javascripts/jobs/components/log/log.vue b/app/assets/javascripts/jobs/components/log/log.vue index eb0de53f36a13938d3973473d2946e4dcee23fdc..f0bdbde0602a06a763c1bd52cbb7a8a14b237179 100644 --- a/app/assets/javascripts/jobs/components/log/log.vue +++ b/app/assets/javascripts/jobs/components/log/log.vue @@ -49,7 +49,7 @@ export default { }; </script> <template> - <code class="job-log d-block"> + <code class="job-log d-block" data-qa-selector="job_log_content"> <template v-for="(section, index) in trace"> <collpasible-log-section v-if="section.isHeader" diff --git a/app/assets/javascripts/jobs/components/stages_dropdown.vue b/app/assets/javascripts/jobs/components/stages_dropdown.vue index 6e92b599b0ad874d53087696d87072591ab97a11..09f9647a680379874446573434684ece41c7019f 100644 --- a/app/assets/javascripts/jobs/components/stages_dropdown.vue +++ b/app/assets/javascripts/jobs/components/stages_dropdown.vue @@ -2,12 +2,10 @@ import _ from 'underscore'; import { GlLink } from '@gitlab/ui'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; -import Icon from '~/vue_shared/components/icon.vue'; export default { components: { CiIcon, - Icon, GlLink, }, props: { diff --git a/app/assets/javascripts/jobs/svg/scroll_down.svg b/app/assets/javascripts/jobs/svg/scroll_down.svg index 1d22870ec09ecf58a22a0b7d4ab6e3bd51c9395a..fb934f68704d3267ee950fab8d55f0aa4aebd536 100644 --- a/app/assets/javascripts/jobs/svg/scroll_down.svg +++ b/app/assets/javascripts/jobs/svg/scroll_down.svg @@ -1,5 +1,4 @@ -<svg width="12" height="16" viewBox="0 0 12 16" xmlns="http://www.w3.org/2000/svg"> - <path class="first-triangle" d="M1.048 14.155a.508.508 0 0 0-.32.105c-.091.07-.136.154-.136.25v.71c0 .095.045.178.135.249.09.07.197.105.321.105h10.043c.124 0 .23-.035.321-.105.09-.07.136-.154.136-.25v-.71c0-.095-.045-.178-.136-.249a.508.508 0 0 0-.32-.105"/> - <path class="second-triangle" d="M.687 8.027c-.09-.087-.122-.16-.093-.22.028-.06.104-.09.228-.09h10.5c.123 0 .2.03.228.09.029.06-.002.133-.093.22L6.393 12.91a.458.458 0 0 1-.136.089h-.37a.626.626 0 0 1-.136-.09"/> - <path class="third-triangle" d="M.687 1.027C.597.94.565.867.594.807c.028-.06.104-.09.228-.09h10.5c.123 0 .2.03.228.09.029.06-.002.133-.093.22L6.393 5.91A.458.458 0 0 1 6.257 6h-.37a.626.626 0 0 1-.136-.09"/> +<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path class="scroll-arrow" d="M8 10.4142L4.29289 6.70711C3.90237 6.31658 3.90237 5.68342 4.29289 5.2929C4.68342 4.90237 5.31658 4.90237 5.70711 5.2929L7 6.58579L7 1C7 0.447715 7.44772 0 8 0C8.55229 0 9 0.447715 9 1L9 6.58579L10.2929 5.2929C10.6834 4.90237 11.3166 4.90237 11.7071 5.2929C12.0976 5.68342 12.0976 6.31658 11.7071 6.70711L8 10.4142Z"/> +<path class="scroll-dot" d="M8 16C9.10457 16 10 15.1046 10 14C10 12.8954 9.10457 12 8 12C6.89543 12 6 12.8954 6 14C6 15.1046 6.89543 16 8 16Z"/> </svg> diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js index 6abf723be9ac873edc55cf3ea6dd8b7586362b6c..f57febbda3710edad9c63fc8a6c7b2e4008c0204 100644 --- a/app/assets/javascripts/labels_select.js +++ b/app/assets/javascripts/labels_select.js @@ -45,6 +45,7 @@ export default class LabelsSelect { const $sidebarLabelTooltip = $block.find('.js-sidebar-labels-tooltip'); const $value = $block.find('.value'); const $dropdownMenu = $dropdown.parent().find('.dropdown-menu'); + // eslint-disable-next-line no-jquery/no-fade const $loading = $block.find('.block-loading').fadeOut(); const fieldName = $dropdown.data('fieldName'); let initialSelected = $selectbox @@ -84,6 +85,7 @@ export default class LabelsSelect { if (!selected.length) { data[abilityName].label_ids = ['']; } + // eslint-disable-next-line no-jquery/no-fade $loading.removeClass('hidden').fadeIn(); $dropdown.trigger('loading.gl.dropdown'); axios @@ -91,6 +93,7 @@ export default class LabelsSelect { .then(({ data }) => { let labelTooltipTitle; let template; + // eslint-disable-next-line no-jquery/no-fade $loading.fadeOut(); $dropdown.trigger('loaded.gl.dropdown'); $selectbox.hide(); @@ -361,6 +364,7 @@ export default class LabelsSelect { const label = clickEvent.selectedObj; const fadeOutLoader = () => { + // eslint-disable-next-line no-jquery/no-fade $loading.fadeOut(); }; @@ -422,6 +426,7 @@ export default class LabelsSelect { boardsStore.detail.issue.labels = labels; } + // eslint-disable-next-line no-jquery/no-fade $loading.fadeIn(); const oldLabels = boardsStore.detail.issue.labels; diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index e4001e9447866ad19bc22a8b8f5f37cffb4e7dd2..a259118003901146072ae6e088605d41325e5777 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -2,12 +2,12 @@ * @module common-utils */ +import { GlBreakpointInstance as breakpointInstance } from '@gitlab/ui/dist/utils'; import $ from 'jquery'; import axios from './axios_utils'; import { getLocationHash } from './url_utility'; import { convertToCamelCase, convertToSnakeCase } from './text_utility'; import { isObject } from './type_utility'; -import breakpointInstance from '../../breakpoints'; export const getPagePath = (index = 0) => { const page = $('body').attr('data-page') || ''; @@ -135,7 +135,9 @@ export const handleLocationHash = () => { adjustment -= topPadding; } - window.scrollBy(0, adjustment); + setTimeout(() => { + window.scrollBy(0, adjustment); + }); }; // Check if element scrolled into viewport from above or below @@ -247,6 +249,7 @@ export const scrollToElement = element => { } const { top } = $el.offset(); + // eslint-disable-next-line no-jquery/no-animate return $('body, html').animate( { scrollTop: top - contentTop(), @@ -480,6 +483,16 @@ export const historyPushState = newUrl => { window.history.pushState({}, document.title, newUrl); }; +/** + * Based on the current location and the string parameters provided + * overwrites the current entry in the history without reloading the page. + * + * @param {String} param + */ +export const historyReplaceState = newUrl => { + window.history.replaceState({}, document.title, newUrl); +}; + /** * Returns true for a String value of "true" and false otherwise. * This is the opposite of Boolean(...).toString(). diff --git a/app/assets/javascripts/lib/utils/datetime_utility.js b/app/assets/javascripts/lib/utils/datetime_utility.js index 996692bacb34932cdda470b01d7064a517937899..fd9a13be18bb6bbcabcf566e434fa408924a4d6f 100644 --- a/app/assets/javascripts/lib/utils/datetime_utility.js +++ b/app/assets/javascripts/lib/utils/datetime_utility.js @@ -392,15 +392,21 @@ export const getTimeframeWindowFrom = (initialStartDate, length) => { * @param {Date} date * @param {Array} quarter */ -export const dayInQuarter = (date, quarter) => - quarter.reduce((acc, month) => { - if (date.getMonth() > month.getMonth()) { +export const dayInQuarter = (date, quarter) => { + const dateValues = { + date: date.getDate(), + month: date.getMonth(), + }; + + return quarter.reduce((acc, month) => { + if (dateValues.month > month.getMonth()) { return acc + totalDaysInMonth(month); - } else if (date.getMonth() === month.getMonth()) { - return acc + date.getDate(); + } else if (dateValues.month === month.getMonth()) { + return acc + dateValues.date; } return acc + 0; }, 0); +}; window.gl = window.gl || {}; window.gl.utils = { @@ -464,7 +470,7 @@ export const pikadayToString = date => { */ export const parseSeconds = ( seconds, - { daysPerWeek = 5, hoursPerDay = 8, limitToHours = false } = {}, + { daysPerWeek = 5, hoursPerDay = 8, limitToHours = false, limitToDays = false } = {}, ) => { const DAYS_PER_WEEK = daysPerWeek; const HOURS_PER_DAY = hoursPerDay; @@ -480,8 +486,11 @@ export const parseSeconds = ( minutes: 1, }; - if (limitToHours) { + if (limitToDays || limitToHours) { timePeriodConstraints.weeks = 0; + } + + if (limitToHours) { timePeriodConstraints.days = 0; } @@ -546,6 +555,16 @@ export const calculateRemainingMilliseconds = endDate => { export const getDateInPast = (date, daysInPast) => new Date(newDate(date).setDate(date.getDate() - daysInPast)); +/** + * Adds a given number of days to a given date and returns the new date. + * + * @param {Date} date the date that we will add days to + * @param {Number} daysInFuture number of days that are added to a given date + * @returns {Date} Date in future as Date object + */ +export const getDateInFuture = (date, daysInFuture) => + new Date(newDate(date).setDate(date.getDate() + daysInFuture)); + /* * Appending T00:00:00 makes JS assume local time and prevents it from shifting the date * to match the user's time zone. We want to display the date in server time for now, to @@ -606,3 +625,44 @@ export const secondsToDays = seconds => Math.round(seconds / 86400); * @return {Date} the date following the date provided */ export const dayAfter = date => new Date(newDate(date).setDate(date.getDate() + 1)); + +/** + * Mimics the behaviour of the rails distance_of_time_in_words function + * https://api.rubyonrails.org/v6.0.1/classes/ActionView/Helpers/DateHelper.html#method-i-distance_of_time_in_words + * 0 < -> 29 secs => less than a minute + * 30 secs < -> 1 min, 29 secs => 1 minute + * 1 min, 30 secs < -> 44 mins, 29 secs => [2..44] minutes + * 44 mins, 30 secs < -> 89 mins, 29 secs => about 1 hour + * 89 mins, 30 secs < -> 23 hrs, 59 mins, 29 secs => about[2..24]hours + * 23 hrs, 59 mins, 30 secs < -> 41 hrs, 59 mins, 29 secs => 1 day + * 41 hrs, 59 mins, 30 secs => x days + * + * @param {Number} seconds + * @return {String} approximated time + */ +export const approximateDuration = (seconds = 0) => { + if (!_.isNumber(seconds) || seconds < 0) { + return ''; + } + + const ONE_MINUTE_LIMIT = 90; // 1 minute 30s + const MINUTES_LIMIT = 2670; // 44 minutes 30s + const ONE_HOUR_LIMIT = 5370; // 89 minutes 30s + const HOURS_LIMIT = 86370; // 23 hours 59 minutes 30s + const ONE_DAY_LIMIT = 151170; // 41 hours 59 minutes 30s + + const { days = 0, hours = 0, minutes = 0 } = parseSeconds(seconds, { + daysPerWeek: 7, + hoursPerDay: 24, + limitToDays: true, + }); + + if (seconds < 30) { + return __('less than a minute'); + } else if (seconds < MINUTES_LIMIT) { + return n__('1 minute', '%d minutes', seconds < ONE_MINUTE_LIMIT ? 1 : minutes); + } else if (seconds < HOURS_LIMIT) { + return n__('about 1 hour', 'about %d hours', seconds < ONE_HOUR_LIMIT ? 1 : hours); + } + return n__('1 day', '%d days', seconds < ONE_DAY_LIMIT ? 1 : days); +}; diff --git a/app/assets/javascripts/lib/utils/keycodes.js b/app/assets/javascripts/lib/utils/keycodes.js index 5e0f9b612a2aa248d02aa9b5f118b0452e207d74..2270d329c247bf67c76b6d98cf06a9f28a8acc7f 100644 --- a/app/assets/javascripts/lib/utils/keycodes.js +++ b/app/assets/javascripts/lib/utils/keycodes.js @@ -2,3 +2,4 @@ export const UP_KEY_CODE = 38; export const DOWN_KEY_CODE = 40; export const ENTER_KEY_CODE = 13; export const ESC_KEY_CODE = 27; +export const BACKSPACE_KEY_CODE = 8; diff --git a/app/assets/javascripts/lib/utils/poll_until_complete.js b/app/assets/javascripts/lib/utils/poll_until_complete.js new file mode 100644 index 0000000000000000000000000000000000000000..199d0e6f0f75e29b9d75300f57cf3f9f17278b91 --- /dev/null +++ b/app/assets/javascripts/lib/utils/poll_until_complete.js @@ -0,0 +1,42 @@ +import axios from '~/lib/utils/axios_utils'; +import Poll from './poll'; +import httpStatusCodes from './http_status'; + +/** + * Polls an endpoint until it returns either a 200 OK or a error status. + * The Poll-Interval header in the responses are used to determine how + * frequently to poll. + * + * Once a 200 OK is received, the promise resolves with that response. If an + * error status is received, the promise rejects with the error. + * + * @param {string} url - The URL to poll. + * @param {Object} [config] - The config to provide to axios.get(). + * @returns {Promise} + */ +export default (url, config = {}) => + new Promise((resolve, reject) => { + const eTagPoll = new Poll({ + resource: { + axiosGet(data) { + return axios.get(data.url, { + headers: { + 'Content-Type': 'application/json', + }, + ...data.config, + }); + }, + }, + data: { url, config }, + method: 'axiosGet', + successCallback: response => { + if (response.status === httpStatusCodes.OK) { + resolve(response); + eTagPoll.stop(); + } + }, + errorCallback: reject, + }); + + eTagPoll.makeRequest(); + }); diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js index 6bbf118d7d1aa52c682ddb36874681f4c0d52a94..a03fedcd7e7d62b32c138701391fd5896a009de0 100644 --- a/app/assets/javascripts/lib/utils/text_utility.js +++ b/app/assets/javascripts/lib/utils/text_utility.js @@ -21,12 +21,17 @@ export const addDelimiter = text => export const highCountTrim = count => (count > 99 ? '99+' : count); /** - * Converts first char to uppercase and replaces undercores with spaces - * @param {String} string + * Converts first char to uppercase and replaces the given separator with spaces + * @param {String} string - The string to humanize + * @param {String} separator - The separator used to separate words (defaults to "_") * @requires {String} + * @returns {String} */ -export const humanize = string => - string.charAt(0).toUpperCase() + string.replace(/_/g, ' ').slice(1); +export const humanize = (string, separator = '_') => { + const replaceRegex = new RegExp(separator, 'g'); + + return string.charAt(0).toUpperCase() + string.replace(replaceRegex, ' ').slice(1); +}; /** * Replaces underscores with dashes @@ -45,7 +50,11 @@ export const slugify = (str, separator = '-') => { const slug = str .trim() .toLowerCase() - .replace(/[^a-zA-Z0-9_.-]+/g, separator); + .replace(/[^a-zA-Z0-9_.-]+/g, separator) + // Remove any duplicate separators or separator prefixes/suffixes + .split(separator) + .filter(Boolean) + .join(separator); return slug === separator ? '' : slug; }; @@ -159,6 +168,15 @@ export const convertToSentenceCase = string => { return splitWord.join(' '); }; +/** + * Converts a sentence to title case + * e.g. Hello world => Hello World + * + * @param {String} string + * @returns {String} + */ +export const convertToTitleCase = string => string.replace(/\b[a-z]/g, s => s.toUpperCase()); + /** * Splits camelCase or PascalCase words * e.g. HelloWorld => Hello World diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index 674415c9d01dec244f0b866190491ae877f17134..d755e7e8cdb5ff158b21b9022022753be4744bc3 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -19,7 +19,7 @@ import { getLocationHash, visitUrl } from './lib/utils/url_utility'; // everything else import loadAwardsHandler from './awards_handler'; -import bp from './breakpoints'; +import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import Flash, { removeFlashClickListener } from './flash'; import './gl_dropdown'; import initTodoToggle from './header'; @@ -55,9 +55,18 @@ jQuery.ajaxSetup({ }, }); +function disableJQueryAnimations() { + $.fx.off = true; +} + +// Disable jQuery animations +if (gon && gon.disable_animations) { + disableJQueryAnimations(); +} + // inject test utilities if necessary if (process.env.NODE_ENV !== 'production' && gon && gon.test_env) { - $.fx.off = true; + disableJQueryAnimations(); import(/* webpackMode: "eager" */ './test_utils/'); } @@ -113,6 +122,7 @@ function deferredInitialisation() { }); $('.js-remove-tr').on('ajax:success', function removeTRAjaxSuccessCallback() { + // eslint-disable-next-line no-jquery/no-fade $(this) .closest('tr') .fadeOut(); @@ -184,7 +194,7 @@ document.addEventListener('DOMContentLoaded', () => { } }); - if (bootstrapBreakpoint === 'xs') { + if (bootstrapBreakpoint === 'sm' || bootstrapBreakpoint === 'xs') { const $rightSidebar = $('aside.right-sidebar, .layout-page'); $rightSidebar.removeClass('right-sidebar-expanded').addClass('right-sidebar-collapsed'); @@ -212,7 +222,7 @@ document.addEventListener('DOMContentLoaded', () => { // Disable form buttons while a form is submitting $body.on('ajax:complete, ajax:beforeSend, submit', 'form', function ajaxCompleteCallback(e) { - const $buttons = $('[type="submit"], .js-disable-on-submit', this); + const $buttons = $('[type="submit"], .js-disable-on-submit', this).not('.js-no-auto-disable'); switch (e.type) { case 'ajax:beforeSend': case 'submit': @@ -269,7 +279,8 @@ document.addEventListener('DOMContentLoaded', () => { }); $document.on('breakpoint:change', (e, breakpoint) => { - if (breakpoint === 'sm' || breakpoint === 'xs') { + const breakpointSizes = ['md', 'sm', 'xs']; + if (breakpointSizes.includes(breakpoint)) { const $gutterIcon = $sidebarGutterToggle.find('i'); if ($gutterIcon.hasClass('fa-angle-double-right')) { $sidebarGutterToggle.trigger('click'); diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js index 52674107df21875ea05364dbdaade00476c151fa..96c4741fc2e8de07ba0c0ba34d2792b2bfc888af 100644 --- a/app/assets/javascripts/merge_request_tabs.js +++ b/app/assets/javascripts/merge_request_tabs.js @@ -2,12 +2,12 @@ import $ from 'jquery'; import Vue from 'vue'; +import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import Cookies from 'js-cookie'; import axios from './lib/utils/axios_utils'; import flash from './flash'; import BlobForkSuggestion from './blob/blob_fork_suggestion'; import initChangesDropdown from './init_changes_dropdown'; -import bp from './breakpoints'; import { parseUrlPathname, handleLocationHash, @@ -194,7 +194,7 @@ export default class MergeRequestTabs { if (!isInVueNoteablePage()) { this.loadDiff(href); } - if (bp.getBreakpointSize() !== 'lg') { + if (bp.getBreakpointSize() !== 'xl') { this.shrinkView(); } this.expandViewContainer(); diff --git a/app/assets/javascripts/milestone_select.js b/app/assets/javascripts/milestone_select.js index 1738dbe439c20ea3ba4f1901034dec911bc320fb..d15e4ecb53724861b2edd36fd84142eb330b101b 100644 --- a/app/assets/javascripts/milestone_select.js +++ b/app/assets/javascripts/milestone_select.js @@ -52,6 +52,7 @@ export default class MilestoneSelect { const $block = $selectBox.closest('.block'); const $sidebarCollapsedValue = $block.find('.sidebar-collapsed-icon'); const $value = $block.find('.value'); + // eslint-disable-next-line no-jquery/no-fade const $loading = $block.find('.block-loading').fadeOut(); selectedMilestoneDefault = showAny ? '' : null; selectedMilestoneDefault = @@ -202,15 +203,18 @@ export default class MilestoneSelect { } $dropdown.trigger('loading.gl.dropdown'); + // eslint-disable-next-line no-jquery/no-fade $loading.removeClass('hidden').fadeIn(); boardsStore.detail.issue .update($dropdown.attr('data-issue-update')) .then(() => { $dropdown.trigger('loaded.gl.dropdown'); + // eslint-disable-next-line no-jquery/no-fade $loading.fadeOut(); }) .catch(() => { + // eslint-disable-next-line no-jquery/no-fade $loading.fadeOut(); }); } else { @@ -218,12 +222,14 @@ export default class MilestoneSelect { data = {}; data[abilityName] = {}; data[abilityName].milestone_id = selected != null ? selected : null; + // eslint-disable-next-line no-jquery/no-fade $loading.removeClass('hidden').fadeIn(); $dropdown.trigger('loading.gl.dropdown'); return axios .put(issueUpdateURL, data) .then(({ data }) => { $dropdown.trigger('loaded.gl.dropdown'); + // eslint-disable-next-line no-jquery/no-fade $loading.fadeOut(); $selectBox.hide(); $value.css('display', ''); @@ -247,6 +253,7 @@ export default class MilestoneSelect { } }) .catch(() => { + // eslint-disable-next-line no-jquery/no-fade $loading.fadeOut(); }); } diff --git a/app/assets/javascripts/monitoring/components/charts/anomaly.vue b/app/assets/javascripts/monitoring/components/charts/anomaly.vue index 1df7ca37a9899a35a7d4d74f10b421f3b465d6e3..64704701d1a84e01c3f87ddaeb88b12742ff18a0 100644 --- a/app/assets/javascripts/monitoring/components/charts/anomaly.vue +++ b/app/assets/javascripts/monitoring/components/charts/anomaly.vue @@ -1,6 +1,6 @@ <script> import { flatten, isNumber } from 'underscore'; -import { GlLineChart, GlChartSeriesLabel } from '@gitlab/ui/dist/charts'; +import { GlChartSeriesLabel } from '@gitlab/ui/dist/charts'; import { roundOffFloat } from '~/lib/utils/common_utils'; import { hexToRgb } from '~/lib/utils/color_utils'; import { areaOpacityValues, symbolSizes, colorValues } from '../../constants'; @@ -48,7 +48,6 @@ const AREA_COLOR_RGBA = `rgba(${hexToRgb(AREA_COLOR).join(',')},${AREA_OPACITY}) */ export default { components: { - GlLineChart, GlChartSeriesLabel, MonitorTimeSeriesChart, }, diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue index c1ca5449ba397ac5f2140db9b228fb27d3f8539d..b03ee12aef37ed874a72d24ea383f22cc981ab53 100644 --- a/app/assets/javascripts/monitoring/components/dashboard.vue +++ b/app/assets/javascripts/monitoring/components/dashboard.vue @@ -17,28 +17,35 @@ import createFlash from '~/flash'; import Icon from '~/vue_shared/components/icon.vue'; import { getParameterValues, mergeUrlParams, redirectTo } from '~/lib/utils/url_utility'; import invalidUrl from '~/lib/utils/invalid_url'; + import DateTimePicker from './date_time_picker/date_time_picker.vue'; import GraphGroup from './graph_group.vue'; import EmptyState from './empty_state.vue'; import GroupEmptyState from './group_empty_state.vue'; +import DashboardsDropdown from './dashboards_dropdown.vue'; + import TrackEventDirective from '~/vue_shared/directives/track_event'; -import { getTimeDiff, isValidDate, getAddMetricTrackingOptions } from '../utils'; +import { getTimeDiff, getAddMetricTrackingOptions } from '../utils'; import { metricStates } from '../constants'; +const defaultTimeDiff = getTimeDiff(); + export default { components: { VueDraggable, PanelType, - GraphGroup, - EmptyState, - GroupEmptyState, Icon, GlButton, GlDropdown, GlDropdownItem, GlFormGroup, GlModal, + DateTimePicker, + GraphGroup, + EmptyState, + GroupEmptyState, + DashboardsDropdown, }, directives: { GlModal: GlModalDirective, @@ -81,6 +88,10 @@ export default { type: String, required: true, }, + defaultBranch: { + type: String, + required: true, + }, metricsEndpoint: { type: String, required: true, @@ -138,6 +149,11 @@ export default { required: false, default: invalidUrl, }, + dashboardsEndpoint: { + type: String, + required: false, + default: invalidUrl, + }, currentDashboard: { type: String, required: false, @@ -168,9 +184,10 @@ export default { return { state: 'gettingStarted', formIsValid: null, - selectedTimeWindow: {}, - isRearrangingPanels: false, + startDate: getParameterValues('start')[0] || defaultTimeDiff.start, + endDate: getParameterValues('end')[0] || defaultTimeDiff.end, hasValidDates: true, + isRearrangingPanels: false, }; }, computed: { @@ -196,9 +213,6 @@ export default { selectedDashboard() { return this.allDashboards.find(d => d.path === this.currentDashboard) || this.firstDashboard; }, - selectedDashboardText() { - return this.selectedDashboard.display_name; - }, showRearrangePanelsBtn() { return !this.showEmptyState && this.rearrangePanelsAvailable; }, @@ -220,6 +234,7 @@ export default { environmentsEndpoint: this.environmentsEndpoint, deploymentsEndpoint: this.deploymentsEndpoint, dashboardEndpoint: this.dashboardEndpoint, + dashboardsEndpoint: this.dashboardsEndpoint, currentDashboard: this.currentDashboard, projectPath: this.projectPath, }); @@ -228,24 +243,10 @@ export default { if (!this.hasMetrics) { this.setGettingStartedEmptyState(); } else { - const defaultRange = getTimeDiff(); - const start = getParameterValues('start')[0] || defaultRange.start; - const end = getParameterValues('end')[0] || defaultRange.end; - - const range = { - start, - end, - }; - - this.selectedTimeWindow = range; - - if (!isValidDate(start) || !isValidDate(end)) { - this.hasValidDates = false; - this.showInvalidDateError(); - } else { - this.hasValidDates = true; - this.fetchData(range); - } + this.fetchData({ + start: this.startDate, + end: this.endDate, + }); } }, methods: { @@ -267,9 +268,20 @@ export default { key, }); }, - showInvalidDateError() { - createFlash(s__('Metrics|Link contains an invalid time window.')); + + onDateTimePickerApply(params) { + redirectTo(mergeUrlParams(params, window.location.href)); + }, + onDateTimePickerInvalid() { + createFlash( + s__( + 'Metrics|Link contains an invalid time window, please verify the link to see the requested time range.', + ), + ); + this.startDate = defaultTimeDiff.start; + this.endDate = defaultTimeDiff.end; }, + generateLink(group, title, yLabel) { const dashboard = this.currentDashboard || this.firstDashboard.path; const params = _.pick({ dashboard, group, title, y_label: yLabel }, value => value != null); @@ -287,9 +299,6 @@ export default { submitCustomMetricsForm() { this.$refs.customMetricsForm.submit(); }, - onDateTimePickerApply(timeWindowUrlParams) { - return redirectTo(mergeUrlParams(timeWindowUrlParams, window.location.href)); - }, /** * Return a single empty state for a group. * @@ -317,6 +326,13 @@ export default { return !this.getMetricStates(groupKey).includes(metricStates.OK); }, getAddMetricTrackingOptions, + + selectDashboard(dashboard) { + const params = { + dashboard: dashboard.path, + }; + redirectTo(mergeUrlParams(params, window.location.href)); + }, }, addMetric: { title: s__('Metrics|Add metric'), @@ -336,21 +352,14 @@ export default { label-for="monitor-dashboards-dropdown" class="col-sm-12 col-md-6 col-lg-2" > - <gl-dropdown + <dashboards-dropdown id="monitor-dashboards-dropdown" - class="mb-0 d-flex js-dashboards-dropdown" + class="mb-0 d-flex" toggle-class="dropdown-menu-toggle" - :text="selectedDashboardText" - > - <gl-dropdown-item - v-for="dashboard in allDashboards" - :key="dashboard.path" - :active="dashboard.path === currentDashboard" - active-class="is-active" - :href="`?dashboard=${dashboard.path}`" - >{{ dashboard.display_name || dashboard.path }}</gl-dropdown-item - > - </gl-dropdown> + :default-branch="defaultBranch" + :selected-dashboard="selectedDashboard" + @selectDashboard="selectDashboard($event)" + /> </gl-form-group> <gl-form-group @@ -378,15 +387,16 @@ export default { </gl-form-group> <gl-form-group - v-if="hasValidDates" :label="s__('Metrics|Show last')" label-size="sm" label-for="monitor-time-window-dropdown" class="col-sm-6 col-md-6 col-lg-4" > <date-time-picker - :selected-time-window="selectedTimeWindow" - @onApply="onDateTimePickerApply" + :start="startDate" + :end="endDate" + @apply="onDateTimePickerApply" + @invalid="onDateTimePickerInvalid" /> </gl-form-group> </template> diff --git a/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue b/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue new file mode 100644 index 0000000000000000000000000000000000000000..6d93eee0b4fb820f60f0ef919caefdd4e04b83e6 --- /dev/null +++ b/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue @@ -0,0 +1,139 @@ +<script> +import { mapState, mapActions } from 'vuex'; +import { + GlAlert, + GlDropdown, + GlDropdownItem, + GlDropdownDivider, + GlModal, + GlLoadingIcon, + GlModalDirective, +} from '@gitlab/ui'; +import DuplicateDashboardForm from './duplicate_dashboard_form.vue'; + +const events = { + selectDashboard: 'selectDashboard', +}; + +export default { + components: { + GlAlert, + GlDropdown, + GlDropdownItem, + GlDropdownDivider, + GlModal, + GlLoadingIcon, + DuplicateDashboardForm, + }, + directives: { + GlModal: GlModalDirective, + }, + props: { + selectedDashboard: { + type: Object, + required: false, + default: () => ({}), + }, + defaultBranch: { + type: String, + required: true, + }, + }, + data() { + return { + alert: null, + loading: false, + form: {}, + }; + }, + computed: { + ...mapState('monitoringDashboard', ['allDashboards']), + isSystemDashboard() { + return this.selectedDashboard.system_dashboard; + }, + selectedDashboardText() { + return this.selectedDashboard.display_name; + }, + }, + methods: { + ...mapActions('monitoringDashboard', ['duplicateSystemDashboard']), + selectDashboard(dashboard) { + this.$emit(events.selectDashboard, dashboard); + }, + ok(bvModalEvt) { + // Prevent modal from hiding in case submit fails + bvModalEvt.preventDefault(); + + this.loading = true; + this.alert = null; + this.duplicateSystemDashboard(this.form) + .then(createdDashboard => { + this.loading = false; + this.alert = null; + + // Trigger hide modal as submit is successful + this.$refs.duplicateDashboardModal.hide(); + + // Dashboards in the default branch become available immediately. + // Not so in other branches, so we refresh the current dashboard + const dashboard = + this.form.branch === this.defaultBranch ? createdDashboard : this.selectedDashboard; + this.$emit(events.selectDashboard, dashboard); + }) + .catch(error => { + this.loading = false; + this.alert = error; + }); + }, + hide() { + this.alert = null; + }, + formChange(form) { + this.form = form; + }, + }, +}; +</script> +<template> + <gl-dropdown toggle-class="dropdown-menu-toggle" :text="selectedDashboardText"> + <gl-dropdown-item + v-for="dashboard in allDashboards" + :key="dashboard.path" + :active="dashboard.path === selectedDashboard.path" + active-class="is-active" + @click="selectDashboard(dashboard)" + > + {{ dashboard.display_name || dashboard.path }} + </gl-dropdown-item> + + <template v-if="isSystemDashboard"> + <gl-dropdown-divider /> + + <gl-modal + ref="duplicateDashboardModal" + modal-id="duplicateDashboardModal" + :title="s__('Metrics|Duplicate dashboard')" + ok-variant="success" + @ok="ok" + @hide="hide" + > + <gl-alert v-if="alert" class="mb-3" variant="danger" @dismiss="alert = null"> + {{ alert }} + </gl-alert> + <duplicate-dashboard-form + :dashboard="selectedDashboard" + :default-branch="defaultBranch" + @change="formChange" + /> + <template #modal-ok> + <gl-loading-icon v-if="loading" inline color="light" /> + {{ loading ? s__('Metrics|Duplicating...') : s__('Metrics|Duplicate') }} + </template> + </gl-modal> + + <gl-dropdown-item ref="duplicateDashboardItem" v-gl-modal="'duplicateDashboardModal'"> + {{ s__('Metrics|Duplicate dashboard') }} + </gl-dropdown-item> + </template> + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/monitoring/components/date_time_picker/date_time_picker.vue b/app/assets/javascripts/monitoring/components/date_time_picker/date_time_picker.vue index 8749019c5cd32508bdc7e4e12b70a73217e00240..0aa710b1b3a9bd9d38416b3aa888b85b8ab57f78 100644 --- a/app/assets/javascripts/monitoring/components/date_time_picker/date_time_picker.vue +++ b/app/assets/javascripts/monitoring/components/date_time_picker/date_time_picker.vue @@ -5,14 +5,21 @@ import Icon from '~/vue_shared/components/icon.vue'; import DateTimePickerInput from './date_time_picker_input.vue'; import { getTimeDiff, + isValidDate, getTimeWindow, stringToISODate, ISODateToString, truncateZerosInDateTime, isDateTimePickerInputValid, } from '~/monitoring/utils'; + import { timeWindows } from '~/monitoring/constants'; +const events = { + apply: 'apply', + invalid: 'invalid', +}; + export default { components: { Icon, @@ -23,77 +30,94 @@ export default { GlDropdownItem, }, props: { + start: { + type: String, + required: true, + }, + end: { + type: String, + required: true, + }, timeWindows: { type: Object, required: false, default: () => timeWindows, }, - selectedTimeWindow: { - type: Object, - required: false, - default: () => {}, - }, }, data() { return { - selectedTimeWindowText: '', - customTime: { - from: null, - to: null, - }, + startDate: this.start, + endDate: this.end, }; }, computed: { - applyEnabled() { - return Boolean(this.inputState.from && this.inputState.to); + startInputValid() { + return isValidDate(this.startDate); }, - inputState() { - const { from, to } = this.customTime; - return { - from: from && isDateTimePickerInputValid(from), - to: to && isDateTimePickerInputValid(to), - }; + endInputValid() { + return isValidDate(this.endDate); }, - }, - watch: { - selectedTimeWindow() { - this.verifyTimeRange(); + isValid() { + return this.startInputValid && this.endInputValid; + }, + + startInput: { + get() { + return this.startInputValid ? this.formatDate(this.startDate) : this.startDate; + }, + set(val) { + // Attempt to set a formatted date if possible + this.startDate = isDateTimePickerInputValid(val) ? stringToISODate(val) : val; + }, + }, + endInput: { + get() { + return this.endInputValid ? this.formatDate(this.endDate) : this.endDate; + }, + set(val) { + // Attempt to set a formatted date if possible + this.endDate = isDateTimePickerInputValid(val) ? stringToISODate(val) : val; + }, + }, + + timeWindowText() { + const timeWindow = getTimeWindow({ start: this.start, end: this.end }); + if (timeWindow) { + return this.timeWindows[timeWindow]; + } else if (isValidDate(this.start) && isValidDate(this.end)) { + return sprintf(s__('%{start} to %{end}'), { + start: this.formatDate(this.start), + end: this.formatDate(this.end), + }); + } + return ''; }, }, mounted() { - this.verifyTimeRange(); + // Validate on mounted, and trigger an update if needed + if (!this.isValid) { + this.$emit(events.invalid); + } }, methods: { - activeTimeWindow(key) { - return this.timeWindows[key] === this.selectedTimeWindowText; + formatDate(date) { + return truncateZerosInDateTime(ISODateToString(date)); }, - setCustomTimeWindowParameter() { - this.$emit('onApply', { - start: stringToISODate(this.customTime.from), - end: stringToISODate(this.customTime.to), - }); - }, - setTimeWindowParameter(key) { + setTimeWindow(key) { const { start, end } = getTimeDiff(key); - this.$emit('onApply', { - start, - end, - }); + this.startDate = start; + this.endDate = end; + + this.apply(); }, closeDropdown() { this.$refs.dropdown.hide(); }, - verifyTimeRange() { - const range = getTimeWindow(this.selectedTimeWindow); - if (range) { - this.selectedTimeWindowText = this.timeWindows[range]; - } else { - this.customTime = { - from: truncateZerosInDateTime(ISODateToString(this.selectedTimeWindow.start)), - to: truncateZerosInDateTime(ISODateToString(this.selectedTimeWindow.end)), - }; - this.selectedTimeWindowText = sprintf(s__('%{from} to %{to}'), this.customTime); - } + apply() { + this.$emit(events.apply, { + start: this.startDate, + end: this.endDate, + }); }, }, }; @@ -101,7 +125,7 @@ export default { <template> <gl-dropdown ref="dropdown" - :text="selectedTimeWindowText" + :text="timeWindowText" menu-class="time-window-dropdown-menu" class="js-time-window-dropdown" > @@ -113,24 +137,21 @@ export default { > <date-time-picker-input id="custom-time-from" - v-model="customTime.from" + v-model="startInput" :label="__('From')" - :state="inputState.from" + :state="startInputValid" /> <date-time-picker-input id="custom-time-to" - v-model="customTime.to" + v-model="endInput" :label="__('To')" - :state="inputState.to" + :state="endInputValid" /> <gl-form-group> <gl-button @click="closeDropdown">{{ __('Cancel') }}</gl-button> - <gl-button - variant="success" - :disabled="!applyEnabled" - @click="setCustomTimeWindowParameter" - >{{ __('Apply') }}</gl-button - > + <gl-button variant="success" :disabled="!isValid" @click="apply()"> + {{ __('Apply') }} + </gl-button> </gl-form-group> </gl-form-group> <gl-form-group @@ -142,14 +163,14 @@ export default { <gl-dropdown-item v-for="(value, key) in timeWindows" :key="key" - :active="activeTimeWindow(key)" + :active="value === timeWindowText" active-class="active" - @click="setTimeWindowParameter(key)" + @click="setTimeWindow(key)" > <icon name="mobile-issue-close" class="align-bottom" - :class="{ invisible: !activeTimeWindow(key) }" + :class="{ invisible: value !== timeWindowText }" /> {{ value }} </gl-dropdown-item> diff --git a/app/assets/javascripts/monitoring/components/duplicate_dashboard_form.vue b/app/assets/javascripts/monitoring/components/duplicate_dashboard_form.vue new file mode 100644 index 0000000000000000000000000000000000000000..e678957c1e5c9df28316f0672801a717c1cd7bc1 --- /dev/null +++ b/app/assets/javascripts/monitoring/components/duplicate_dashboard_form.vue @@ -0,0 +1,138 @@ +<script> +import { __, s__, sprintf } from '~/locale'; +import { GlFormGroup, GlFormInput, GlFormRadioGroup, GlFormTextarea } from '@gitlab/ui'; + +const defaultFileName = dashboard => dashboard.path.split('/').reverse()[0]; + +export default { + components: { + GlFormGroup, + GlFormInput, + GlFormRadioGroup, + GlFormTextarea, + }, + props: { + dashboard: { + type: Object, + required: true, + }, + defaultBranch: { + type: String, + required: true, + }, + }, + radioVals: { + /* Use the default branch (e.g. master) */ + DEFAULT: 'DEFAULT', + /* Create a new branch */ + NEW: 'NEW', + }, + data() { + return { + form: { + dashboard: this.dashboard.path, + fileName: defaultFileName(this.dashboard), + commitMessage: '', + }, + branchName: '', + branchOption: this.$options.radioVals.NEW, + branchOptions: [ + { + value: this.$options.radioVals.DEFAULT, + html: sprintf( + __('Commit to %{branchName} branch'), + { + branchName: `<strong>${this.defaultBranch}</strong>`, + }, + false, + ), + }, + { value: this.$options.radioVals.NEW, text: __('Create new branch') }, + ], + }; + }, + computed: { + defaultCommitMsg() { + return sprintf(s__('Metrics|Create custom dashboard %{fileName}'), { + fileName: this.form.fileName, + }); + }, + fileNameState() { + // valid if empty or *.yml + return !(this.form.fileName && !this.form.fileName.endsWith('.yml')); + }, + fileNameFeedback() { + return !this.fileNameState ? s__('The file name should have a .yml extension') : ''; + }, + }, + mounted() { + this.change(); + }, + methods: { + change() { + this.$emit('change', { + ...this.form, + commitMessage: this.form.commitMessage || this.defaultCommitMsg, + branch: + this.branchOption === this.$options.radioVals.NEW ? this.branchName : this.defaultBranch, + }); + }, + focus(option) { + if (option === this.$options.radioVals.NEW) { + this.$nextTick(() => { + this.$refs.branchName.$el.focus(); + }); + } + }, + }, +}; +</script> +<template> + <form @change="change"> + <p class="text-muted"> + {{ + s__(`Metrics|You can save a copy of this dashboard to your repository + so it can be customized. Select a file name and branch to + save it.`) + }} + </p> + <gl-form-group + ref="fileNameFormGroup" + :label="__('File name')" + :state="fileNameState" + :invalid-feedback="fileNameFeedback" + label-size="sm" + label-for="fileName" + > + <gl-form-input id="fileName" ref="fileName" v-model="form.fileName" :required="true" /> + </gl-form-group> + <gl-form-group :label="__('Branch')" label-size="sm" label-for="branch"> + <gl-form-radio-group + ref="branchOption" + v-model="branchOption" + :checked="$options.radioVals.NEW" + :stacked="true" + :options="branchOptions" + @change="focus" + /> + <gl-form-input + v-show="branchOption === $options.radioVals.NEW" + id="branchName" + ref="branchName" + v-model="branchName" + /> + </gl-form-group> + <gl-form-group + :label="__('Commit message (optional)')" + label-size="sm" + label-for="commitMessage" + > + <gl-form-textarea + id="commitMessage" + ref="commitMessage" + v-model="form.commitMessage" + :placeholder="defaultCommitMsg" + /> + </gl-form-group> + </form> +</template> diff --git a/app/assets/javascripts/monitoring/components/embed.vue b/app/assets/javascripts/monitoring/components/embed.vue index eb8945c1a57d420a5df7cd449c996ebbb5f2768c..2f562071764bea3d731e5bc24008c34a1de76c7a 100644 --- a/app/assets/javascripts/monitoring/components/embed.vue +++ b/app/assets/javascripts/monitoring/components/embed.vue @@ -2,7 +2,6 @@ import { mapActions, mapState, mapGetters } from 'vuex'; import PanelType from 'ee_else_ce/monitoring/components/panel_type.vue'; import { getParameterValues, removeParams } from '~/lib/utils/url_utility'; -import GraphGroup from './graph_group.vue'; import { sidebarAnimationDuration } from '../constants'; import { getTimeDiff } from '../utils'; @@ -10,7 +9,6 @@ let sidebarMutationObserver; export default { components: { - GraphGroup, PanelType, }, props: { diff --git a/app/assets/javascripts/monitoring/components/shared/prometheus_header.vue b/app/assets/javascripts/monitoring/components/shared/prometheus_header.vue index 153c8f389dba664eef6120c76c2f51cfdb8bf6f2..ceeec51ee650aff471f0ac7ea46ad6d04f4068ed 100644 --- a/app/assets/javascripts/monitoring/components/shared/prometheus_header.vue +++ b/app/assets/javascripts/monitoring/components/shared/prometheus_header.vue @@ -10,6 +10,6 @@ export default { </script> <template> <div class="prometheus-graph-header"> - <h5 class="prometheus-graph-title js-graph-title">{{ graphTitle }}</h5> + <h5 ref="title" class="prometheus-graph-title">{{ graphTitle }}</h5> </div> </template> diff --git a/app/assets/javascripts/monitoring/stores/actions.js b/app/assets/javascripts/monitoring/stores/actions.js index 1cb82ce0083751b093163861298cfec092d99889..61cd86219025f4cd75793c00791bfde1fc4613ac 100644 --- a/app/assets/javascripts/monitoring/stores/actions.js +++ b/app/assets/javascripts/monitoring/stores/actions.js @@ -39,7 +39,7 @@ export const requestMetricsDashboard = ({ commit }) => { }; export const receiveMetricsDashboardSuccess = ({ commit, dispatch }, { response, params }) => { commit(types.SET_ALL_DASHBOARDS, response.all_dashboards); - commit(types.RECEIVE_METRICS_DATA_SUCCESS, response.dashboard.panel_groups); + commit(types.RECEIVE_METRICS_DATA_SUCCESS, response.dashboard); return dispatch('fetchPrometheusMetrics', params); }; export const receiveMetricsDashboardFailure = ({ commit }, error) => { @@ -214,5 +214,29 @@ export const setPanelGroupMetrics = ({ commit }, data) => { commit(types.SET_PANEL_GROUP_METRICS, data); }; +export const duplicateSystemDashboard = ({ state }, payload) => { + const params = { + dashboard: payload.dashboard, + file_name: payload.fileName, + branch: payload.branch, + commit_message: payload.commitMessage, + }; + + return axios + .post(state.dashboardsEndpoint, params) + .then(response => response.data) + .then(data => data.dashboard) + .catch(error => { + const { response } = error; + if (response && response.data && response.data.error) { + throw sprintf(s__('Metrics|There was an error creating the dashboard. %{error}'), { + error: response.data.error, + }); + } else { + throw s__('Metrics|There was an error creating the dashboard.'); + } + }); +}; + // prevent babel-plugin-rewire from generating an invalid default during karma tests export default () => {}; diff --git a/app/assets/javascripts/monitoring/stores/mutations.js b/app/assets/javascripts/monitoring/stores/mutations.js index 16a34a6c02624386e09c0a1806f3e2e8b4aeb4ad..506a30ae61919a13d3fc100dedba4e9731a0bfd8 100644 --- a/app/assets/javascripts/monitoring/stores/mutations.js +++ b/app/assets/javascripts/monitoring/stores/mutations.js @@ -84,23 +84,26 @@ export default { state.emptyState = 'loading'; state.showEmptyState = true; }, - [types.RECEIVE_METRICS_DATA_SUCCESS](state, groupData) { - state.dashboard.panel_groups = groupData.map((group, i) => { - const key = `${slugify(group.group || 'default')}-${i}`; - let { panels = [] } = group; - - // each panel has metric information that needs to be normalized - panels = panels.map(panel => ({ - ...panel, - metrics: normalizePanelMetrics(panel.metrics, panel.y_label), - })); - - return { - ...group, - panels, - key, - }; - }); + [types.RECEIVE_METRICS_DATA_SUCCESS](state, dashboard) { + state.dashboard = { + ...dashboard, + panel_groups: dashboard.panel_groups.map((group, i) => { + const key = `${slugify(group.group || 'default')}-${i}`; + let { panels = [] } = group; + + // each panel has metric information that needs to be normalized + panels = panels.map(panel => ({ + ...panel, + metrics: normalizePanelMetrics(panel.metrics, panel.y_label), + })); + + return { + ...group, + panels, + key, + }; + }), + }; if (!state.dashboard.panel_groups.length) { state.emptyState = 'noData'; @@ -172,6 +175,7 @@ export default { state.environmentsEndpoint = endpoints.environmentsEndpoint; state.deploymentsEndpoint = endpoints.deploymentsEndpoint; state.dashboardEndpoint = endpoints.dashboardEndpoint; + state.dashboardsEndpoint = endpoints.dashboardsEndpoint; state.currentDashboard = endpoints.currentDashboard; state.projectPath = endpoints.projectPath; }, diff --git a/app/assets/javascripts/mr_popover/components/mr_popover.vue b/app/assets/javascripts/mr_popover/components/mr_popover.vue index ce08b0964a127cb751e32356ba571b8d16f35258..bbc2feae812f58d6f5a53e5a06829f0174e6fe19 100644 --- a/app/assets/javascripts/mr_popover/components/mr_popover.vue +++ b/app/assets/javascripts/mr_popover/components/mr_popover.vue @@ -1,7 +1,6 @@ <script> /* eslint-disable @gitlab/vue-i18n/no-bare-strings */ import { GlPopover, GlSkeletonLoading } from '@gitlab/ui'; -import Icon from '../../vue_shared/components/icon.vue'; import CiIcon from '../../vue_shared/components/ci_icon.vue'; import timeagoMixin from '../../vue_shared/mixins/timeago'; import query from '../queries/merge_request.query.graphql'; @@ -13,7 +12,6 @@ export default { components: { GlPopover, GlSkeletonLoading, - Icon, CiIcon, }, mixins: [timeagoMixin], diff --git a/app/assets/javascripts/mr_tabs_popover/components/popover.vue b/app/assets/javascripts/mr_tabs_popover/components/popover.vue index da1e1e70993b3048590ed7841a8c6fb4a6e61c79..f8293d2a473b07e5d8e356c4994f7614535a2563 100644 --- a/app/assets/javascripts/mr_tabs_popover/components/popover.vue +++ b/app/assets/javascripts/mr_tabs_popover/components/popover.vue @@ -57,7 +57,12 @@ export default { <icon name="external-link" :size="10" /> </gl-link> </p> - <gl-button variant="primary" size="sm" @click="onDismiss"> + <gl-button + variant="primary" + size="sm" + data-qa-selector="dismiss_popover_button" + @click="onDismiss" + > {{ __('Got it') }} </gl-button> </gl-popover> diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js index 1a8f1c659a4205054c31b9b045dab7c215f12069..4195ea6425f1f1839ae11373288ece87d583327c 100644 --- a/app/assets/javascripts/notes.js +++ b/app/assets/javascripts/notes.js @@ -1359,7 +1359,8 @@ export default class Notes { const $systemNote = $(systemNote); const headerMessage = $systemNote .find('.note-text') - .find('p:first') + .find('p') + .first() .text() .replace(':', ''); diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue index 492d8de38023caa9a6f971e676fc5ef3a83289c4..4ca32b9b0051c57c34846455a92bc659384a1358 100644 --- a/app/assets/javascripts/notes/components/comment_form.vue +++ b/app/assets/javascripts/notes/components/comment_form.vue @@ -336,6 +336,7 @@ export default { <markdown-field ref="markdownField" + :is-submitting="isSubmitting" :markdown-preview-path="markdownPreviewPath" :markdown-docs-path="markdownDocsPath" :quick-actions-docs-path="quickActionsDocsPath" diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue index 1f31720ff406f44f026d52cd58a2fded33636297..3462ee72dd3947904871a9c899756e2278f7ea9a 100644 --- a/app/assets/javascripts/notes/components/noteable_discussion.vue +++ b/app/assets/javascripts/notes/components/noteable_discussion.vue @@ -89,6 +89,9 @@ export default { currentUser() { return this.getUserData; }, + isLoggedIn() { + return Boolean(gon.current_user_id); + }, autosaveKey() { return getDiscussionReplyKey(this.firstNote.noteable_type, this.discussion.id); }, @@ -314,7 +317,7 @@ export default { @cancelForm="cancelReplyForm" /> </div> - <note-signed-out-widget v-if="!userCanReply" /> + <note-signed-out-widget v-if="!isLoggedIn" /> </div> </template> </discussion-notes> diff --git a/app/assets/javascripts/notifications_dropdown.js b/app/assets/javascripts/notifications_dropdown.js index 08545dcea463ef4586622a76f11ef1d4874aedb2..ab87b0d973c1e2c9e54c11905809f35e766e7c7a 100644 --- a/app/assets/javascripts/notifications_dropdown.js +++ b/app/assets/javascripts/notifications_dropdown.js @@ -11,7 +11,9 @@ export default function notificationsDropdown() { } const notificationLevel = $(this).data('notificationLevel'); - const form = $(this).parents('.notification-form:first'); + const form = $(this) + .parents('.notification-form') + .first(); form.find('.js-notification-loading').toggleClass('fa-bell fa-spin fa-spinner'); if (form.hasClass('no-label')) { diff --git a/app/assets/javascripts/notifications_form.js b/app/assets/javascripts/notifications_form.js index 45f033f282282590f1f7f55b9c40aa383db3b2a6..dcd226795a63fd31c1d6dc2ea589ec018f592a1e 100644 --- a/app/assets/javascripts/notifications_form.js +++ b/app/assets/javascripts/notifications_form.js @@ -31,7 +31,7 @@ export default class NotificationsForm { } saveEvent($checkbox, $parent) { - const form = $parent.parents('form:first'); + const form = $parent.parents('form').first(); this.showCheckboxLoadingSpinner($parent); diff --git a/app/assets/javascripts/pages/admin/admin.js b/app/assets/javascripts/pages/admin/admin.js index 4616a0757298760bfab191d4fd6b2965a2a2c333..88967d82b2f07a3a0ab74ce7088b75fea7d36bc6 100644 --- a/app/assets/javascripts/pages/admin/admin.js +++ b/app/assets/javascripts/pages/admin/admin.js @@ -36,6 +36,8 @@ export default function adminInit() { $('.log-bottom').on('click', e => { e.preventDefault(); const $visibleLog = $('.file-content:visible'); + + // eslint-disable-next-line no-jquery/no-animate $visibleLog.animate( { scrollTop: $visibleLog.find('ol').height(), diff --git a/app/assets/javascripts/pages/admin/application_settings/index.js b/app/assets/javascripts/pages/admin/application_settings/index.js index 47bd70537f1aebebc1d56bd5c4d83cc5cf7128bb..089dedd14cb5e9b9cf45088e33d0ae9963ff3cb3 100644 --- a/app/assets/javascripts/pages/admin/application_settings/index.js +++ b/app/assets/javascripts/pages/admin/application_settings/index.js @@ -1,7 +1,11 @@ import initSettingsPanels from '~/settings_panels'; import projectSelect from '~/project_select'; +import selfMonitor from '~/self_monitor'; document.addEventListener('DOMContentLoaded', () => { + if (gon.features && gon.features.selfMonitoringProject) { + selfMonitor(); + } // Initialize expandable settings panels initSettingsPanels(); projectSelect(); diff --git a/app/assets/javascripts/pages/admin/broadcast_messages/broadcast_message.js b/app/assets/javascripts/pages/admin/broadcast_messages/broadcast_message.js index 7a6a486f55138a8bbc5ea329ce6d0d4816a1d9b2..7c2008d9edc9bb26e1a92915152819f2273a791f 100644 --- a/app/assets/javascripts/pages/admin/broadcast_messages/broadcast_message.js +++ b/app/assets/javascripts/pages/admin/broadcast_messages/broadcast_message.js @@ -6,21 +6,31 @@ import { __ } from '~/locale'; import { textColorForBackground } from '~/lib/utils/color_utils'; export default () => { - const $broadcastMessageColor = $('input#broadcast_message_color'); - const $broadcastMessagePreview = $('div.broadcast-message-preview'); + const $broadcastMessageColor = $('.js-broadcast-message-color'); + const $broadcastMessageType = $('.js-broadcast-message-type'); + const $broadcastBannerMessagePreview = $('.js-broadcast-banner-message-preview'); + const $broadcastMessage = $('.js-broadcast-message-message'); + const previewPath = $broadcastMessage.data('previewPath'); + const $jsBroadcastMessagePreview = $('.js-broadcast-message-preview'); + $broadcastMessageColor.on('input', function onMessageColorInput() { const previewColor = $(this).val(); - $broadcastMessagePreview.css('background-color', previewColor); + $broadcastBannerMessagePreview.css('background-color', previewColor); }); $('input#broadcast_message_font').on('input', function onMessageFontInput() { const previewColor = $(this).val(); - $broadcastMessagePreview.css('color', previewColor); + $broadcastBannerMessagePreview.css('color', previewColor); }); - const $broadcastMessage = $('textarea#broadcast_message_message'); - const previewPath = $broadcastMessage.data('previewPath'); - const $jsBroadcastMessagePreview = $('.js-broadcast-message-preview'); + $broadcastMessageType.on('change', () => { + const $broadcastMessageColorFormGroup = $('.js-broadcast-message-background-color-form-group'); + const $broadcastNotificationMessagePreview = $('.js-broadcast-notification-message-preview'); + + $broadcastMessageColorFormGroup.toggleClass('hidden'); + $broadcastBannerMessagePreview.toggleClass('hidden'); + $broadcastNotificationMessagePreview.toggleClass('hidden'); + }); $broadcastMessage.on( 'input', @@ -58,7 +68,7 @@ export default () => { $('.label-color-preview').css(selectedColorStyle); - return $broadcastMessagePreview.css(selectedColorStyle); + return $jsBroadcastMessagePreview.css(selectedColorStyle); }; const setSuggestedColor = e => { @@ -67,7 +77,10 @@ export default () => { .val(color) // Notify the form, that color has changed .trigger('input'); - updateColorPreview(); + // Only banner supports colors + if ($broadcastMessageType === 'banner') { + updateColorPreview(); + } return e.preventDefault(); }; diff --git a/app/assets/javascripts/pages/groups/group_members/index/index.js b/app/assets/javascripts/pages/groups/group_members/index/index.js index e77a7cf8e0ac1bd6e6ff114499ecbfd6616dc3b7..0c732922e811cbe17b1d3163ee6567a6e0d2b7a5 100644 --- a/app/assets/javascripts/pages/groups/group_members/index/index.js +++ b/app/assets/javascripts/pages/groups/group_members/index/index.js @@ -3,9 +3,12 @@ import Members from 'ee_else_ce/members'; import memberExpirationDate from '~/member_expiration_date'; import UsersSelect from '~/users_select'; +import groupsSelect from '~/groups_select'; document.addEventListener('DOMContentLoaded', () => { memberExpirationDate(); + memberExpirationDate('.js-access-expiration-date-groups'); new Members(); + groupsSelect(); new UsersSelect(); }); diff --git a/app/assets/javascripts/pages/projects/issues/show.js b/app/assets/javascripts/pages/projects/issues/show.js index 28a136a5fa58aba8973a4656be2c58e94bd7dea2..75df80a0f6c5bd68928e598e5aac2a42a37d216d 100644 --- a/app/assets/javascripts/pages/projects/issues/show.js +++ b/app/assets/javascripts/pages/projects/issues/show.js @@ -4,11 +4,13 @@ import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable'; import ZenMode from '~/zen_mode'; import '~/notes/index'; import initIssueableApp from '~/issue_show'; +import initSentryErrorStackTraceApp from '~/sentry_error_stack_trace'; import initRelatedMergeRequestsApp from '~/related_merge_requests'; import initVueIssuableSidebarApp from '~/issuable_sidebar/sidebar_bundle'; export default function() { initIssueableApp(); + initSentryErrorStackTraceApp(); initRelatedMergeRequestsApp(); new Issue(); // eslint-disable-line no-new new ShortcutsIssuable(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/projects/pipelines/index/index.js b/app/assets/javascripts/pages/projects/pipelines/index/index.js index fd72d2ddbe06296cfed33eed34740a833e778e63..4b4a274794dc46b6f691b1c1ed3a515f043daa6f 100644 --- a/app/assets/javascripts/pages/projects/pipelines/index/index.js +++ b/app/assets/javascripts/pages/projects/pipelines/index/index.js @@ -1,10 +1,18 @@ import Vue from 'vue'; +import { GlToast } from '@gitlab/ui'; +import { doesHashExistInUrl } from '~/lib/utils/url_utility'; +import { + parseBoolean, + historyReplaceState, + buildUrlWithCurrentLocation, +} from '~/lib/utils/common_utils'; +import { __ } from '~/locale'; import PipelinesStore from '../../../../pipelines/stores/pipelines_store'; import pipelinesComponent from '../../../../pipelines/components/pipelines.vue'; import Translate from '../../../../vue_shared/translate'; -import { parseBoolean } from '../../../../lib/utils/common_utils'; Vue.use(Translate); +Vue.use(GlToast); document.addEventListener( 'DOMContentLoaded', @@ -21,6 +29,11 @@ document.addEventListener( }, created() { this.dataset = document.querySelector(this.$options.el).dataset; + + if (doesHashExistInUrl('delete_success')) { + this.$toast.show(__('The pipeline has been deleted')); + historyReplaceState(buildUrlWithCurrentLocation()); + } }, render(createElement) { return createElement('pipelines-component', { diff --git a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue index 4802cc2ad25d3fde9080643cb951a64921600599..6994f83bce04d9f2c5dc29b1a81ac6c3490a9e67 100644 --- a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue +++ b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue @@ -82,6 +82,11 @@ export default { required: false, default: false, }, + pagesAccessControlForced: { + type: Boolean, + required: false, + default: false, + }, pagesHelpPath: { type: String, required: false, @@ -99,6 +104,7 @@ export default { visibilityLevel: visibilityOptions.PUBLIC, issuesAccessLevel: 20, repositoryAccessLevel: 20, + forkingAccessLevel: 20, mergeRequestsAccessLevel: 20, buildsAccessLevel: 20, wikiAccessLevel: 20, @@ -130,10 +136,22 @@ export default { }, pagesFeatureAccessLevelOptions() { - if (this.visibilityLevel !== visibilityOptions.PUBLIC) { - return this.featureAccessLevelOptions.concat([[30, PAGE_FEATURE_ACCESS_LEVEL]]); + const options = [featureAccessLevelMembers]; + + if (this.pagesAccessControlForced) { + if (this.visibilityLevel === visibilityOptions.INTERNAL) { + options.push(featureAccessLevelEveryone); + } + } else { + if (this.visibilityLevel !== visibilityOptions.PRIVATE) { + options.push(featureAccessLevelEveryone); + } + + if (this.visibilityLevel !== visibilityOptions.PUBLIC) { + options.push([30, PAGE_FEATURE_ACCESS_LEVEL]); + } } - return this.featureAccessLevelOptions; + return options; }, repositoryEnabled() { @@ -283,6 +301,19 @@ export default { name="project[project_feature_attributes][merge_requests_access_level]" /> </project-setting-row> + <project-setting-row + :label="s__('ProjectSettings|Forks')" + :help-text=" + s__('ProjectSettings|Allow users to make copies of your repository to a new project') + " + > + <project-feature-setting + v-model="forkingAccessLevel" + :options="featureAccessLevelOptions" + :disabled-input="!repositoryEnabled" + name="project[project_feature_attributes][forking_access_level]" + /> + </project-setting-row> <project-setting-row :label="s__('ProjectSettings|Pipelines')" :help-text="s__('ProjectSettings|Build, test, and deploy your changes')" diff --git a/app/assets/javascripts/pages/projects/wikis/wikis.js b/app/assets/javascripts/pages/projects/wikis/wikis.js index d41199f6374800bc34330f29b5424f5381a5c091..80b628591348d2752ba07dee1a37d2cf389f7dc5 100644 --- a/app/assets/javascripts/pages/projects/wikis/wikis.js +++ b/app/assets/javascripts/pages/projects/wikis/wikis.js @@ -1,4 +1,4 @@ -import bp from '../../../breakpoints'; +import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import { s__, sprintf } from '~/locale'; export default class Wikis { @@ -52,7 +52,7 @@ export default class Wikis { static sidebarCanCollapse() { const bootstrapBreakpoint = bp.getBreakpointSize(); - return bootstrapBreakpoint === 'xs'; + return bootstrapBreakpoint === 'xs' || bootstrapBreakpoint === 'sm'; } renderSidebar() { diff --git a/app/assets/javascripts/performance_bar/components/detailed_metric.vue b/app/assets/javascripts/performance_bar/components/detailed_metric.vue index 7ce32032ed3839bc5fa880e52b7a62152795393a..24ae900b445dc45323a3d6cb8a4c01c71d75de27 100644 --- a/app/assets/javascripts/performance_bar/components/detailed_metric.vue +++ b/app/assets/javascripts/performance_bar/components/detailed_metric.vue @@ -59,7 +59,8 @@ export default { <div v-if="currentRequest.details && metricDetails" :id="`peek-view-${metric}`" - class="view qa-performance-bar-detailed-metric" + class="view" + data-qa-selector="detailed_metric_content" > <button :data-target="`#modal-peek-${metric}-details`" diff --git a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue index d17c2f33adcad5bd04303af4a2fdea6e5347f60b..1df5562e1b6d45a3b059f9cb16595a3186079710 100644 --- a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue +++ b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue @@ -107,7 +107,11 @@ export default { </script> <template> <div id="js-peek" :class="env"> - <div v-if="currentRequest" class="d-flex container-fluid container-limited qa-performance-bar"> + <div + v-if="currentRequest" + class="d-flex container-fluid container-limited" + data-qa-selector="performance_bar" + > <div id="peek-view-host" class="view"> <span v-if="hasHost" diff --git a/app/assets/javascripts/performance_bar/components/request_selector.vue b/app/assets/javascripts/performance_bar/components/request_selector.vue index 1610534ae0db0ebf59794c98ef3932913497243f..115b2ff08ac44da46104f6d663ee962b0e5e086c 100644 --- a/app/assets/javascripts/performance_bar/components/request_selector.vue +++ b/app/assets/javascripts/performance_bar/components/request_selector.vue @@ -45,13 +45,13 @@ export default { }; </script> <template> - <div id="peek-request-selector"> + <div id="peek-request-selector" data-qa-selector="request_dropdown"> <select v-model="currentRequestId"> <option v-for="request in requests" :key="request.id" :value="request.id" - class="qa-performance-bar-request" + data-qa-selector="request_dropdown_option" > {{ request.truncatedUrl }} <span v-if="request.hasWarnings">(!)</span> diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component.vue b/app/assets/javascripts/pipelines/components/graph/graph_component.vue index 429122c8083fadec2ee1d402c3b55dd0a27cbb38..4dc6e51d2fc8209c4353008e6fc665765f0dabf4 100644 --- a/app/assets/javascripts/pipelines/components/graph/graph_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/graph_component.vue @@ -43,7 +43,7 @@ export default { downstream: 'downstream', data() { return { - triggeredTopIndex: 1, + downstreamMarginTop: null, }; }, computed: { @@ -77,26 +77,34 @@ export default { expandedTriggered() { return this.pipeline.triggered && this.pipeline.triggered.find(el => el.isExpanded); }, - - /** - * Calculates the margin top of the clicked downstream pipeline by - * adding the height of each linked pipeline and the margin - */ - marginTop() { - return `${this.triggeredTopIndex * 52}px`; - }, pipelineTypeUpstream() { return this.type !== this.$options.downstream && this.expandedTriggeredBy; }, pipelineTypeDownstream() { return this.type !== this.$options.upstream && this.expandedTriggered; }, + pipelineProjectId() { + return this.pipeline.project.id; + }, }, methods: { - handleClickedDownstream(pipeline, clickedIndex) { - this.triggeredTopIndex = clickedIndex; + handleClickedDownstream(pipeline, clickedIndex, downstreamNode) { + /** + * Calculates the margin top of the clicked downstream pipeline by + * subtracting the clicked downstream pipelines offsetTop by it's parent's + * offsetTop and then subtracting either 15 (if child) or 30 (if not a child) + * due to the height of node and stage name margin bottom. + */ + this.downstreamMarginTop = this.calculateMarginTop( + downstreamNode, + downstreamNode.classList.contains('child-pipeline') ? 15 : 30, + ); + this.$emit('onClickTriggered', this.pipeline, pipeline); }, + calculateMarginTop(downstreamNode, pixelDiff) { + return `${downstreamNode.offsetTop - downstreamNode.offsetParent.offsetTop - pixelDiff}px`; + }, hasOnlyOneJob(stage) { return stage.groups.length === 1; }, @@ -139,6 +147,7 @@ export default { v-if="hasTriggeredBy" :linked-pipelines="triggeredByPipelines" :column-title="__('Upstream')" + :project-id="pipelineProjectId" graph-position="left" @linkedPipelineClick=" linkedPipeline => $emit('onClickTriggeredBy', pipeline, linkedPipeline) @@ -174,6 +183,7 @@ export default { v-if="hasTriggered" :linked-pipelines="triggeredPipelines" :column-title="__('Downstream')" + :project-id="pipelineProjectId" graph-position="right" @linkedPipelineClick="handleClickedDownstream" /> @@ -186,7 +196,7 @@ export default { :is-loading="false" :pipeline="expandedTriggered" :is-linked-pipeline="true" - :style="{ 'margin-top': marginTop }" + :style="{ 'margin-top': downstreamMarginTop }" :mediator="mediator" @onClickTriggered=" (parentPipeline, pipeline) => clickTriggeredPipeline(parentPipeline, pipeline) diff --git a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue index 82335e71403e83d2f18dc9cf4a7596091549d817..d929398b6dc6c933029552825d0a2e2ef5c6dcea 100644 --- a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue +++ b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue @@ -1,6 +1,7 @@ <script> import { GlLoadingIcon, GlTooltipDirective, GlButton } from '@gitlab/ui'; import CiStatus from '~/vue_shared/components/ci_icon.vue'; +import { __ } from '~/locale'; export default { directives: { @@ -16,6 +17,14 @@ export default { type: Object, required: true, }, + projectId: { + type: Number, + required: true, + }, + columnTitle: { + type: String, + required: true, + }, }, computed: { tooltipText() { @@ -30,18 +39,45 @@ export default { projectName() { return this.pipeline.project.name; }, + parentPipeline() { + // Refactor string match when BE returns Upstream/Downstream indicators + return this.projectId === this.pipeline.project.id && this.columnTitle === __('Upstream'); + }, + childPipeline() { + // Refactor string match when BE returns Upstream/Downstream indicators + return this.projectId === this.pipeline.project.id && this.columnTitle === __('Downstream'); + }, + label() { + return this.parentPipeline ? __('Parent') : __('Child'); + }, + childTooltipText() { + return __('This pipeline was triggered by a parent pipeline'); + }, + parentTooltipText() { + return __('This pipeline triggered a child pipeline'); + }, + labelToolTipText() { + return this.label === __('Parent') ? this.parentTooltipText : this.childTooltipText; + }, }, methods: { onClickLinkedPipeline() { this.$root.$emit('bv::hide::tooltip', this.buttonId); - this.$emit('pipelineClicked'); + this.$emit('pipelineClicked', this.$refs.linkedPipeline); + }, + hideTooltips() { + this.$root.$emit('bv::hide::tooltip'); }, }, }; </script> <template> - <li class="linked-pipeline build"> + <li + ref="linkedPipeline" + class="linked-pipeline build" + :class="{ 'child-pipeline': childPipeline }" + > <gl-button :id="buttonId" v-gl-tooltip @@ -59,6 +95,15 @@ export default { class="js-linked-pipeline-status" /> <span class="str-truncated align-bottom"> {{ projectName }} • #{{ pipeline.id }} </span> + <div v-if="parentPipeline || childPipeline" class="parent-child-label-container"> + <span + v-gl-tooltip.bottom + :title="labelToolTipText" + class="badge badge-primary" + @mouseover="hideTooltips" + >{{ label }}</span + > + </div> </gl-button> </li> </template> diff --git a/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue b/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue index 998519f9df1ef0925d72fefb484d512c14ac48bc..e3429184c05522848d758bd34455c60c2f402a74 100644 --- a/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue +++ b/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue @@ -19,6 +19,10 @@ export default { type: String, required: true, }, + projectId: { + type: Number, + required: true, + }, }, computed: { columnClass() { @@ -28,10 +32,16 @@ export default { }; return `graph-position-${this.graphPosition} ${positionValues[this.graphPosition]}`; }, + // Refactor string match when BE returns Upstream/Downstream indicators isUpstream() { return this.columnTitle === __('Upstream'); }, }, + methods: { + onPipelineClick(downstreamNode, pipeline, index) { + this.$emit('linkedPipelineClick', pipeline, index, downstreamNode); + }, + }, }; </script> @@ -48,7 +58,9 @@ export default { 'left-connector': pipeline.isExpanded && graphPosition === 'left', }" :pipeline="pipeline" - @pipelineClicked="$emit('linkedPipelineClick', pipeline, index)" + :column-title="columnTitle" + :project-id="projectId" + @pipelineClicked="onPipelineClick($event, pipeline, index)" /> </ul> </div> diff --git a/app/assets/javascripts/pipelines/components/header_component.vue b/app/assets/javascripts/pipelines/components/header_component.vue index 39afa87afc37cf1ee24823972d93fff2f9813e75..726bba7f9f471ae7675022f41dafb136c09b43b2 100644 --- a/app/assets/javascripts/pipelines/components/header_component.vue +++ b/app/assets/javascripts/pipelines/components/header_component.vue @@ -1,14 +1,17 @@ <script> -import { GlLoadingIcon } from '@gitlab/ui'; +import { GlLoadingIcon, GlModal } from '@gitlab/ui'; import ciHeader from '../../vue_shared/components/header_ci_component.vue'; import eventHub from '../event_hub'; import { __ } from '~/locale'; +const DELETE_MODAL_ID = 'pipeline-delete-modal'; + export default { name: 'PipelineHeaderSection', components: { ciHeader, GlLoadingIcon, + GlModal, }, props: { pipeline: { @@ -33,6 +36,11 @@ export default { shouldRenderContent() { return !this.isLoading && Object.keys(this.pipeline).length; }, + deleteModalConfirmationText() { + return __( + 'Are you sure you want to delete this pipeline? Doing so will expire all pipeline caches and delete all related objects, such as builds, logs, artifacts, and triggers. This action cannot be undone.', + ); + }, }, watch: { @@ -42,6 +50,13 @@ export default { }, methods: { + onActionClicked(action) { + if (action.modal) { + this.$root.$emit('bv::show::modal', action.modal); + } else { + this.postAction(action); + } + }, postAction(action) { const index = this.actions.indexOf(action); @@ -49,6 +64,13 @@ export default { eventHub.$emit('headerPostAction', action); }, + deletePipeline() { + const index = this.actions.findIndex(action => action.modal === DELETE_MODAL_ID); + + this.$set(this.actions[index], 'isLoading', true); + + eventHub.$emit('headerDeleteAction', this.actions[index]); + }, getActions() { const actions = []; @@ -58,7 +80,6 @@ export default { label: __('Retry'), path: this.pipeline.retry_path, cssClass: 'js-retry-button btn btn-inverted-secondary', - type: 'button', isLoading: false, }); } @@ -68,7 +89,16 @@ export default { label: __('Cancel running'), path: this.pipeline.cancel_path, cssClass: 'js-btn-cancel-pipeline btn btn-danger', - type: 'button', + isLoading: false, + }); + } + + if (this.pipeline.delete_path) { + actions.push({ + label: __('Delete'), + path: this.pipeline.delete_path, + modal: DELETE_MODAL_ID, + cssClass: 'js-btn-delete-pipeline btn btn-danger btn-inverted', isLoading: false, }); } @@ -76,6 +106,7 @@ export default { return actions; }, }, + DELETE_MODAL_ID, }; </script> <template> @@ -88,8 +119,21 @@ export default { :user="pipeline.user" :actions="actions" item-name="Pipeline" - @actionClicked="postAction" + @actionClicked="onActionClicked" /> + <gl-loading-icon v-if="isLoading" :size="2" class="prepend-top-default append-bottom-default" /> + + <gl-modal + :modal-id="$options.DELETE_MODAL_ID" + :title="__('Delete pipeline')" + :ok-title="__('Delete pipeline')" + ok-variant="danger" + @ok="deletePipeline()" + > + <p> + {{ deleteModalConfirmationText }} + </p> + </gl-modal> </div> </template> diff --git a/app/assets/javascripts/pipelines/components/nav_controls.vue b/app/assets/javascripts/pipelines/components/nav_controls.vue index c6990683ec75c4b6b0fab8c567f3f9c5f5eff63a..5e4147f88058864f5eb7a52fc670e4b0e15d619f 100644 --- a/app/assets/javascripts/pipelines/components/nav_controls.vue +++ b/app/assets/javascripts/pipelines/components/nav_controls.vue @@ -1,12 +1,11 @@ <script> -import { GlLink, GlButton } from '@gitlab/ui'; +import { GlButton } from '@gitlab/ui'; import LoadingButton from '../../vue_shared/components/loading_button.vue'; export default { name: 'PipelineNavControls', components: { LoadingButton, - GlLink, GlButton, }, props: { diff --git a/app/assets/javascripts/pipelines/components/pipeline_stop_modal.vue b/app/assets/javascripts/pipelines/components/pipeline_stop_modal.vue index 7c4e651373f841e837c3ba0393b9261a4f7c31dc..6ca96bbba5e22795e3cedb7042a5fbed0df22e09 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_stop_modal.vue +++ b/app/assets/javascripts/pipelines/components/pipeline_stop_modal.vue @@ -2,7 +2,6 @@ import _ from 'underscore'; import { GlLink } from '@gitlab/ui'; import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue'; -import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; import { s__, sprintf } from '~/locale'; @@ -15,7 +14,6 @@ export default { components: { GlModal: DeprecatedModal2, GlLink, - ClipboardButton, CiIcon, }, props: { diff --git a/app/assets/javascripts/pipelines/components/pipeline_url.vue b/app/assets/javascripts/pipelines/components/pipeline_url.vue index 30c830d78f9e7b90326bca9e2bf021967c3935b0..743c3ea271da897f150c5d6a3fb8abb057fe667c 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_url.vue +++ b/app/assets/javascripts/pipelines/components/pipeline_url.vue @@ -2,7 +2,6 @@ import { GlLink, GlTooltipDirective } from '@gitlab/ui'; import _ from 'underscore'; import { __, sprintf } from '~/locale'; -import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; import popover from '~/vue_shared/directives/popover'; const popoverTitle = sprintf( @@ -17,7 +16,6 @@ const popoverTitle = sprintf( export default { components: { - UserAvatarLink, GlLink, }, directives: { diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue b/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue index 28b2c706320c0b79cfb7dabb5f2ea822b8268468..65c1f125b5506259a357dcdd8f7728b66d056433 100644 --- a/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue +++ b/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue @@ -3,11 +3,13 @@ import { mapGetters } from 'vuex'; import Icon from '~/vue_shared/components/icon.vue'; import store from '~/pipelines/stores/test_reports'; import { __ } from '~/locale'; +import SmartVirtualList from '~/vue_shared/components/smart_virtual_list.vue'; export default { name: 'TestsSuiteTable', components: { Icon, + SmartVirtualList, }, store, props: { @@ -23,6 +25,8 @@ export default { return this.getSuiteTests.length > 0; }, }, + maxShownRows: 30, + typicalRowHeight: 75, }; </script> @@ -34,7 +38,7 @@ export default { </div> </div> - <div v-if="hasSuites" class="test-reports-table js-test-cases-table"> + <div v-if="hasSuites" class="test-reports-table append-bottom-default js-test-cases-table"> <div role="row" class="gl-responsive-table-row table-row-header font-weight-bold fgray"> <div role="rowheader" class="table-section section-20"> {{ __('Class') }} @@ -53,52 +57,58 @@ export default { </div> </div> - <div - v-for="(testCase, index) in getSuiteTests" - :key="index" - class="gl-responsive-table-row rounded align-items-md-start mt-sm-3 js-case-row" + <smart-virtual-list + :length="getSuiteTests.length" + :remain="$options.maxShownRows" + :size="$options.typicalRowHeight" > - <div class="table-section section-20 section-wrap"> - <div role="rowheader" class="table-mobile-header">{{ __('Class') }}</div> - <div class="table-mobile-content pr-md-1">{{ testCase.classname }}</div> - </div> + <div + v-for="(testCase, index) in getSuiteTests" + :key="index" + class="gl-responsive-table-row rounded align-items-md-start mt-xs-3 js-case-row" + > + <div class="table-section section-20 section-wrap"> + <div role="rowheader" class="table-mobile-header">{{ __('Class') }}</div> + <div class="table-mobile-content pr-md-1 text-truncate">{{ testCase.classname }}</div> + </div> - <div class="table-section section-20 section-wrap"> - <div role="rowheader" class="table-mobile-header">{{ __('Name') }}</div> - <div class="table-mobile-content">{{ testCase.name }}</div> - </div> + <div class="table-section section-20 section-wrap"> + <div role="rowheader" class="table-mobile-header">{{ __('Name') }}</div> + <div class="table-mobile-content">{{ testCase.name }}</div> + </div> - <div class="table-section section-10 section-wrap"> - <div role="rowheader" class="table-mobile-header">{{ __('Status') }}</div> - <div class="table-mobile-content text-center"> - <div - class="add-border ci-status-icon d-flex align-items-center justify-content-end justify-content-md-center" - :class="`ci-status-icon-${testCase.status}`" - > - <icon :size="24" :name="testCase.icon" /> + <div class="table-section section-10 section-wrap"> + <div role="rowheader" class="table-mobile-header">{{ __('Status') }}</div> + <div class="table-mobile-content text-center"> + <div + class="add-border ci-status-icon d-flex align-items-center justify-content-end justify-content-md-center" + :class="`ci-status-icon-${testCase.status}`" + > + <icon :size="24" :name="testCase.icon" /> + </div> </div> </div> - </div> - <div class="table-section flex-grow-1"> - <div role="rowheader" class="table-mobile-header">{{ __('Trace'), }}</div> - <div class="table-mobile-content"> - <pre - v-if="testCase.system_output" - class="build-trace build-trace-rounded text-left" - ><code class="bash p-0">{{testCase.system_output}}</code></pre> + <div class="table-section flex-grow-1"> + <div role="rowheader" class="table-mobile-header">{{ __('Trace'), }}</div> + <div class="table-mobile-content"> + <pre + v-if="testCase.system_output" + class="build-trace build-trace-rounded text-left" + ><code class="bash p-0">{{testCase.system_output}}</code></pre> + </div> </div> - </div> - <div class="table-section section-10 section-wrap"> - <div role="rowheader" class="table-mobile-header"> - {{ __('Duration') }} - </div> - <div class="table-mobile-content text-right"> - {{ testCase.formattedTime }} + <div class="table-section section-10 section-wrap"> + <div role="rowheader" class="table-mobile-header"> + {{ __('Duration') }} + </div> + <div class="table-mobile-content text-right pr-sm-1"> + {{ testCase.formattedTime }} + </div> </div> </div> - </div> + </smart-virtual-list> </div> <div v-else> diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue b/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue index 1bac7ce9ac5862672e3ec0ebb79111240a3732e6..2fa3fa41eedb9fe4752773b0fc0a1e1439b93e55 100644 --- a/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue +++ b/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue @@ -1,5 +1,5 @@ <script> -import { GlButton, GlLink, GlProgressBar } from '@gitlab/ui'; +import { GlButton, GlProgressBar } from '@gitlab/ui'; import { __ } from '~/locale'; import { formatTime, secondsToMilliseconds } from '~/lib/utils/datetime_utility'; import Icon from '~/vue_shared/components/icon.vue'; @@ -8,7 +8,6 @@ export default { name: 'TestSummary', components: { GlButton, - GlLink, GlProgressBar, Icon, }, diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue b/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue index 96177512e35c87e231c691cc6ac98786d48ae7cf..6effd6e949dcab551d55259c919e83372667c457 100644 --- a/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue +++ b/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue @@ -2,9 +2,13 @@ import { mapGetters } from 'vuex'; import { s__ } from '~/locale'; import store from '~/pipelines/stores/test_reports'; +import SmartVirtualList from '~/vue_shared/components/smart_virtual_list.vue'; export default { name: 'TestsSummaryTable', + components: { + SmartVirtualList, + }, store, props: { heading: { @@ -24,6 +28,8 @@ export default { this.$emit('row-click', suite); }, }, + maxShownRows: 20, + typicalRowHeight: 55, }; </script> @@ -35,7 +41,7 @@ export default { </div> </div> - <div v-if="hasSuites" class="test-reports-table js-test-suites-table"> + <div v-if="hasSuites" class="test-reports-table append-bottom-default js-test-suites-table"> <div role="row" class="gl-responsive-table-row table-row-header font-weight-bold"> <div role="rowheader" class="table-section section-25 pl-3"> {{ __('Suite') }} @@ -60,66 +66,72 @@ export default { </div> </div> - <div - v-for="(testSuite, index) in getTestSuites" - :key="index" - role="row" - class="gl-responsive-table-row gl-responsive-table-row-clickable test-reports-summary-row rounded cursor-pointer js-suite-row" - @click="tableRowClick(testSuite)" + <smart-virtual-list + :length="getTestSuites.length" + :remain="$options.maxShownRows" + :size="$options.typicalRowHeight" > - <div class="table-section section-25"> - <div role="rowheader" class="table-mobile-header font-weight-bold"> - {{ __('Suite') }} - </div> - <div class="table-mobile-content underline cgray pl-3"> - {{ testSuite.name }} + <div + v-for="(testSuite, index) in getTestSuites" + :key="index" + role="row" + class="gl-responsive-table-row gl-responsive-table-row-clickable test-reports-summary-row rounded cursor-pointer js-suite-row" + @click="tableRowClick(testSuite)" + > + <div class="table-section section-25"> + <div role="rowheader" class="table-mobile-header font-weight-bold"> + {{ __('Suite') }} + </div> + <div class="table-mobile-content underline cgray pl-3"> + {{ testSuite.name }} + </div> </div> - </div> - <div class="table-section section-25"> - <div role="rowheader" class="table-mobile-header font-weight-bold"> - {{ __('Duration') }} + <div class="table-section section-25"> + <div role="rowheader" class="table-mobile-header font-weight-bold"> + {{ __('Duration') }} + </div> + <div class="table-mobile-content text-md-left"> + {{ testSuite.formattedTime }} + </div> </div> - <div class="table-mobile-content text-md-left"> - {{ testSuite.formattedTime }} - </div> - </div> - <div class="table-section section-10 text-center"> - <div role="rowheader" class="table-mobile-header font-weight-bold"> - {{ __('Failed') }} + <div class="table-section section-10 text-center"> + <div role="rowheader" class="table-mobile-header font-weight-bold"> + {{ __('Failed') }} + </div> + <div class="table-mobile-content">{{ testSuite.failed_count }}</div> </div> - <div class="table-mobile-content">{{ testSuite.failed_count }}</div> - </div> - <div class="table-section section-10 text-center"> - <div role="rowheader" class="table-mobile-header font-weight-bold"> - {{ __('Errors') }} + <div class="table-section section-10 text-center"> + <div role="rowheader" class="table-mobile-header font-weight-bold"> + {{ __('Errors') }} + </div> + <div class="table-mobile-content">{{ testSuite.error_count }}</div> </div> - <div class="table-mobile-content">{{ testSuite.error_count }}</div> - </div> - <div class="table-section section-10 text-center"> - <div role="rowheader" class="table-mobile-header font-weight-bold"> - {{ __('Skipped') }} + <div class="table-section section-10 text-center"> + <div role="rowheader" class="table-mobile-header font-weight-bold"> + {{ __('Skipped') }} + </div> + <div class="table-mobile-content">{{ testSuite.skipped_count }}</div> </div> - <div class="table-mobile-content">{{ testSuite.skipped_count }}</div> - </div> - <div class="table-section section-10 text-center"> - <div role="rowheader" class="table-mobile-header font-weight-bold"> - {{ __('Passed') }} + <div class="table-section section-10 text-center"> + <div role="rowheader" class="table-mobile-header font-weight-bold"> + {{ __('Passed') }} + </div> + <div class="table-mobile-content">{{ testSuite.success_count }}</div> </div> - <div class="table-mobile-content">{{ testSuite.success_count }}</div> - </div> - <div class="table-section section-10 text-right pr-md-3"> - <div role="rowheader" class="table-mobile-header font-weight-bold"> - {{ __('Total') }} + <div class="table-section section-10 text-right pr-md-3"> + <div role="rowheader" class="table-mobile-header font-weight-bold"> + {{ __('Total') }} + </div> + <div class="table-mobile-content">{{ testSuite.total_count }}</div> </div> - <div class="table-mobile-content">{{ testSuite.total_count }}</div> </div> - </div> + </smart-virtual-list> </div> <div v-else> diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js index d8dbc3c2454d6ef7f8322d299f58132832fbd6bf..c874c4c6fdd1060f51ece92cc32a59a12f7522a1 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js +++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js @@ -2,6 +2,7 @@ import Vue from 'vue'; import Flash from '~/flash'; import Translate from '~/vue_shared/translate'; import { __ } from '~/locale'; +import { setUrlFragment, redirectTo } from '~/lib/utils/url_utility'; import pipelineGraph from './components/graph/graph_component.vue'; import GraphBundleMixin from './mixins/graph_pipeline_bundle_mixin'; import PipelinesMediator from './pipeline_details_mediator'; @@ -62,9 +63,11 @@ export default () => { }, created() { eventHub.$on('headerPostAction', this.postAction); + eventHub.$on('headerDeleteAction', this.deleteAction); }, beforeDestroy() { eventHub.$off('headerPostAction', this.postAction); + eventHub.$off('headerDeleteAction', this.deleteAction); }, methods: { postAction(action) { @@ -73,6 +76,13 @@ export default () => { .then(() => this.mediator.refreshPipeline()) .catch(() => Flash(__('An error occurred while making the request.'))); }, + deleteAction(action) { + this.mediator.stopPipelinePoll(); + this.mediator.service + .deleteAction(action.path) + .then(({ request }) => redirectTo(setUrlFragment(request.responseURL, 'delete_success'))) + .catch(() => Flash(__('An error occurred while deleting the pipeline.'))); + }, }, render(createElement) { return createElement('pipeline-header', { diff --git a/app/assets/javascripts/pipelines/pipeline_details_mediator.js b/app/assets/javascripts/pipelines/pipeline_details_mediator.js index bf021a0b447e70bf5ca558aa8a6fdb2573b5a85a..f3387f00fc1fff8392e627321d28d3d385ffd854 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_mediator.js +++ b/app/assets/javascripts/pipelines/pipeline_details_mediator.js @@ -35,7 +35,7 @@ export default class pipelinesMediator { if (!Visibility.hidden()) { this.poll.restart(); } else { - this.poll.stop(); + this.stopPipelinePoll(); } }); } @@ -51,7 +51,7 @@ export default class pipelinesMediator { } refreshPipeline() { - this.poll.stop(); + this.stopPipelinePoll(); return this.service .getPipeline() @@ -64,6 +64,10 @@ export default class pipelinesMediator { ); } + stopPipelinePoll() { + this.poll.stop(); + } + /** * Backend expects paramets in the following format: `expanded[]=id&expanded[]=id` */ diff --git a/app/assets/javascripts/pipelines/services/pipeline_service.js b/app/assets/javascripts/pipelines/services/pipeline_service.js index e44eb9cdfd11201a8b99b6fdf412c67488af2c9d..ba2830ec59614748bc48c9d22678a67a1f520eaf 100644 --- a/app/assets/javascripts/pipelines/services/pipeline_service.js +++ b/app/assets/javascripts/pipelines/services/pipeline_service.js @@ -9,6 +9,11 @@ export default class PipelineService { return axios.get(this.pipeline, { params }); } + // eslint-disable-next-line class-methods-use-this + deleteAction(endpoint) { + return axios.delete(`${endpoint}.json`); + } + // eslint-disable-next-line class-methods-use-this postAction(endpoint) { return axios.post(`${endpoint}.json`); diff --git a/app/assets/javascripts/project_find_file.js b/app/assets/javascripts/project_find_file.js index d6cdd37a2c3d89d3a6f25aa3f84585d817491150..a31034361a89dd071668ee9177d7f637c81f25b2 100644 --- a/app/assets/javascripts/project_find_file.js +++ b/app/assets/javascripts/project_find_file.js @@ -55,26 +55,21 @@ export default class ProjectFindFile { initEvent() { this.inputElement.off('keyup'); - this.inputElement.on( - 'keyup', - (function(_this) { - return function(event) { - const target = $(event.target); - const value = target.val(); - const ref = target.data('oldValue'); - const oldValue = ref != null ? ref : ''; - if (value !== oldValue) { - target.data('oldValue', value); - _this.findFile(); - return _this.element - .find('tr.tree-item') - .eq(0) - .addClass('selected') - .focus(); - } - }; - })(this), - ); + this.inputElement.on('keyup', event => { + const target = $(event.target); + const value = target.val(); + const ref = target.data('oldValue'); + const oldValue = ref != null ? ref : ''; + if (value !== oldValue) { + target.data('oldValue', value); + this.findFile(); + return this.element + .find('tr.tree-item') + .eq(0) + .addClass('selected') + .focus(); + } + }); } findFile() { diff --git a/app/assets/javascripts/projects/project_import_gitlab_project.js b/app/assets/javascripts/projects/project_import_gitlab_project.js index fbef3a0b059620ebae8d291511a3f72d774a0318..4f222438500ea905d8125544778ec7855858cacc 100644 --- a/app/assets/javascripts/projects/project_import_gitlab_project.js +++ b/app/assets/javascripts/projects/project_import_gitlab_project.js @@ -1,19 +1,45 @@ import $ from 'jquery'; +import { convertToTitleCase, humanize, slugify } from '../lib/utils/text_utility'; import { getParameterValues } from '../lib/utils/url_utility'; import projectNew from './project_new'; +const prepareParameters = () => { + const name = getParameterValues('name')[0]; + const path = getParameterValues('path')[0]; + + // If the name param exists but the path doesn't then generate it from the name + if (name && !path) { + return { name, path: slugify(name) }; + } + + // If the path param exists but the name doesn't then generate it from the path + if (path && !name) { + return { name: convertToTitleCase(humanize(path, '-')), path }; + } + + return { name, path }; +}; + export default () => { - const pathParam = getParameterValues('path')[0]; - const nameParam = getParameterValues('name')[0]; - const $projectPath = $('.js-path-name'); + let hasUserDefinedProjectName = false; const $projectName = $('.js-project-name'); - - // get the path url and append it in the input - $projectPath.val(pathParam); + const $projectPath = $('.js-path-name'); + const { name, path } = prepareParameters(); // get the project name from the URL and set it as input value - $projectName.val(nameParam); + $projectName.val(name); + + // get the path url and append it in the input + $projectPath.val(path); // generate slug when project name changes - $projectName.keyup(() => projectNew.onProjectNameChange($projectName, $projectPath)); + $projectName.on('keyup', () => { + projectNew.onProjectNameChange($projectName, $projectPath); + hasUserDefinedProjectName = $projectName.val().trim().length > 0; + }); + + // generate project name from the slug if one isn't set + $projectPath.on('keyup', () => + projectNew.onProjectPathChange($projectName, $projectPath, hasUserDefinedProjectName), + ); }; diff --git a/app/assets/javascripts/projects/project_new.js b/app/assets/javascripts/projects/project_new.js index 92c4c05bd87b103fdffc82c79466ccc6d943acb7..2aa5f6ec62672094cf11c7e5f67432be885bc35c 100644 --- a/app/assets/javascripts/projects/project_new.js +++ b/app/assets/javascripts/projects/project_new.js @@ -1,14 +1,45 @@ import $ from 'jquery'; import { addSelectOnFocusBehaviour } from '../lib/utils/common_utils'; -import { slugify } from '../lib/utils/text_utility'; +import { convertToTitleCase, humanize, slugify } from '../lib/utils/text_utility'; import { s__ } from '~/locale'; let hasUserDefinedProjectPath = false; +let hasUserDefinedProjectName = false; + +const onProjectNameChange = ($projectNameInput, $projectPathInput) => { + const slug = slugify($projectNameInput.val()); + $projectPathInput.val(slug); +}; + +const onProjectPathChange = ($projectNameInput, $projectPathInput, hasExistingProjectName) => { + const slug = $projectPathInput.val(); + + if (!hasExistingProjectName) { + $projectNameInput.val(convertToTitleCase(humanize(slug, '[-_]'))); + } +}; + +const setProjectNamePathHandlers = ($projectNameInput, $projectPathInput) => { + $projectNameInput.off('keyup change').on('keyup change', () => { + onProjectNameChange($projectNameInput, $projectPathInput); + hasUserDefinedProjectName = $projectNameInput.val().trim().length > 0; + hasUserDefinedProjectPath = $projectPathInput.val().trim().length > 0; + }); + + $projectPathInput.off('keyup change').on('keyup change', () => { + onProjectPathChange($projectNameInput, $projectPathInput, hasUserDefinedProjectName); + hasUserDefinedProjectPath = $projectPathInput.val().trim().length > 0; + }); +}; const deriveProjectPathFromUrl = $projectImportUrl => { + const $currentProjectName = $projectImportUrl + .parents('.toggle-import-form') + .find('#project_name'); const $currentProjectPath = $projectImportUrl .parents('.toggle-import-form') .find('#project_path'); + if (hasUserDefinedProjectPath) { return; } @@ -30,14 +61,10 @@ const deriveProjectPathFromUrl = $projectImportUrl => { const pathMatch = /\/([^/]+)$/.exec(importUrl); if (pathMatch) { $currentProjectPath.val(pathMatch[1]); + onProjectPathChange($currentProjectName, $currentProjectPath, false); } }; -const onProjectNameChange = ($projectNameInput, $projectPathInput) => { - const slug = slugify($projectNameInput.val()); - $projectPathInput.val(slug); -}; - const bindEvents = () => { const $newProjectForm = $('#new_project'); const $projectImportUrl = $('#project_import_url'); @@ -202,10 +229,7 @@ const bindEvents = () => { const $activeTabProjectName = $('.tab-pane.active #project_name'); const $activeTabProjectPath = $('.tab-pane.active #project_path'); $activeTabProjectName.focus(); - $activeTabProjectName.keyup(() => { - onProjectNameChange($activeTabProjectName, $activeTabProjectPath); - hasUserDefinedProjectPath = $activeTabProjectPath.val().trim().length > 0; - }); + setProjectNamePathHandlers($activeTabProjectName, $activeTabProjectPath); } $useTemplateBtn.on('change', chooseTemplate); @@ -220,26 +244,24 @@ const bindEvents = () => { $projectPath.val($projectPath.val().trim()); }); - $projectPath.on('keyup', () => { - hasUserDefinedProjectPath = $projectPath.val().trim().length > 0; - }); - $projectImportUrl.keyup(() => deriveProjectPathFromUrl($projectImportUrl)); $('.js-import-git-toggle-button').on('click', () => { const $projectMirror = $('#project_mirror'); $projectMirror.attr('disabled', !$projectMirror.attr('disabled')); + setProjectNamePathHandlers( + $('.tab-pane.active #project_name'), + $('.tab-pane.active #project_path'), + ); }); - $projectName.on('keyup change', () => { - onProjectNameChange($projectName, $projectPath); - hasUserDefinedProjectPath = $projectPath.val().trim().length > 0; - }); + setProjectNamePathHandlers($projectName, $projectPath); }; export default { bindEvents, deriveProjectPathFromUrl, onProjectNameChange, + onProjectPathChange, }; diff --git a/app/assets/javascripts/registry/list/components/collapsible_container.vue b/app/assets/javascripts/registry/list/components/collapsible_container.vue index 86bb2d8092e5e0e9030bb42ac06d58116cfba08b..9786a1a3f7589f258e5c23bf17be894f6e06e76c 100644 --- a/app/assets/javascripts/registry/list/components/collapsible_container.vue +++ b/app/assets/javascripts/registry/list/components/collapsible_container.vue @@ -14,7 +14,7 @@ import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import Icon from '~/vue_shared/components/icon.vue'; import TableRegistry from './table_registry.vue'; import { DELETE_REPO_ERROR_MESSAGE } from '../constants'; -import { __ } from '~/locale'; +import { __, sprintf } from '~/locale'; export default { name: 'CollapsibeContainerRegisty', @@ -55,6 +55,11 @@ export default { canDeleteRepo() { return this.repo.canDelete && !this.isDeleteDisabled; }, + deleteImageConfirmationMessage() { + return sprintf(__('Image %{imageName} was scheduled for deletion from the registry.'), { + imageName: this.repo.name, + }); + }, }, methods: { ...mapActions(['fetchRepos', 'fetchList', 'deleteItem']), @@ -69,7 +74,7 @@ export default { this.track('confirm_delete'); return this.deleteItem(this.repo) .then(() => { - createFlash(__('This container registry has been scheduled for deletion.'), 'notice'); + createFlash(this.deleteImageConfirmationMessage, 'notice'); this.fetchRepos(); }) .catch(() => createFlash(DELETE_REPO_ERROR_MESSAGE)); diff --git a/app/assets/javascripts/registry/settings/components/registry_settings_app.vue b/app/assets/javascripts/registry/settings/components/registry_settings_app.vue index b2c700b817cc48168795313ade19fe85aa66965f..ca495cd2ecaa9c852db6f5b4a81445d16265a0f8 100644 --- a/app/assets/javascripts/registry/settings/components/registry_settings_app.vue +++ b/app/assets/javascripts/registry/settings/components/registry_settings_app.vue @@ -1,26 +1,23 @@ <script> -import { mapState } from 'vuex'; -import { s__, sprintf } from '~/locale'; +import { mapState, mapActions } from 'vuex'; +import { GlLoadingIcon } from '@gitlab/ui'; +import SettingsForm from './settings_form.vue'; export default { - components: {}, + components: { + GlLoadingIcon, + SettingsForm, + }, computed: { ...mapState({ - helpPagePath: 'helpPagePath', + isLoading: 'isLoading', }), - - helpText() { - return sprintf( - s__( - 'PackageRegistry|Read more about the %{helpLinkStart}Container Registry tag retention policies%{helpLinkEnd}', - ), - { - helpLinkStart: `<a href="${this.helpPagePath}" target="_blank">`, - helpLinkEnd: '</a>', - }, - false, - ); - }, + }, + mounted() { + this.fetchSettings(); + }, + methods: { + ...mapActions(['fetchSettings']), }, }; </script> @@ -28,16 +25,19 @@ export default { <template> <div> <p> - {{ s__('PackageRegistry|Tag retention policies are designed to:') }} + {{ s__('ContainerRegistry|Tag expiration policy is designed to:') }} </p> <ul> - <li>{{ s__('PackageRegistry|Keep and protect the images that matter most.') }}</li> + <li>{{ s__('ContainerRegistry|Keep and protect the images that matter most.') }}</li> <li> {{ - s__("PackageRegistry|Automatically remove extra images that aren't designed to be kept.") + s__( + "ContainerRegistry|Automatically remove extra images that aren't designed to be kept.", + ) }} </li> </ul> - <p ref="help-link" v-html="helpText"></p> + <gl-loading-icon v-if="isLoading" ref="loading-icon" size="xl" /> + <settings-form v-else ref="settings-form" /> </div> </template> diff --git a/app/assets/javascripts/registry/settings/components/settings_form.vue b/app/assets/javascripts/registry/settings/components/settings_form.vue new file mode 100644 index 0000000000000000000000000000000000000000..457bf35daaba280623e0c823d7a3b8daaf32202a --- /dev/null +++ b/app/assets/javascripts/registry/settings/components/settings_form.vue @@ -0,0 +1,178 @@ +<script> +import { mapActions, mapState } from 'vuex'; +import { GlFormGroup, GlToggle, GlFormSelect, GlFormTextarea, GlButton, GlCard } from '@gitlab/ui'; +import { s__, __, sprintf } from '~/locale'; +import { NAME_REGEX_LENGTH } from '../constants'; +import { mapComputed } from '~/vuex_shared/bindings'; + +export default { + components: { + GlFormGroup, + GlToggle, + GlFormSelect, + GlFormTextarea, + GlButton, + GlCard, + }, + labelsConfig: { + cols: 3, + align: 'right', + }, + computed: { + ...mapState(['formOptions']), + ...mapComputed( + [ + 'enabled', + { key: 'cadence', getter: 'getCadence' }, + { key: 'older_than', getter: 'getOlderThan' }, + { key: 'keep_n', getter: 'getKeepN' }, + 'name_regex', + ], + 'updateSettings', + 'settings', + ), + policyEnabledText() { + return this.enabled ? __('enabled') : __('disabled'); + }, + toggleDescriptionText() { + return sprintf( + s__('ContainerRegistry|Docker tag expiration policy is %{toggleStatus}'), + { + toggleStatus: `<strong>${this.policyEnabledText}</strong>`, + }, + false, + ); + }, + regexHelpText() { + return sprintf( + s__( + 'ContainerRegistry|Wildcards such as %{codeStart}*-stable%{codeEnd} or %{codeStart}production/*%{codeEnd} are supported. To select all tags, use %{codeStart}.*%{codeEnd}', + ), + { + codeStart: '<code>', + codeEnd: '</code>', + }, + false, + ); + }, + nameRegexPlaceholder() { + return '.*'; + }, + nameRegexState() { + return this.name_regex ? this.name_regex.length <= NAME_REGEX_LENGTH : null; + }, + formIsInvalid() { + return this.nameRegexState === false; + }, + }, + methods: { + ...mapActions(['resetSettings', 'saveSettings']), + }, +}; +</script> + +<template> + <form ref="form-element" @submit.prevent="saveSettings" @reset.prevent="resetSettings"> + <gl-card> + <template #header> + {{ s__('ContainerRegistry|Tag expiration policy') }} + </template> + <template> + <gl-form-group + id="expiration-policy-toggle-group" + :label-cols="$options.labelsConfig.cols" + :label-align="$options.labelsConfig.align" + label-for="expiration-policy-toggle" + :label="s__('ContainerRegistry|Expiration policy:')" + > + <div class="d-flex align-items-start"> + <gl-toggle id="expiration-policy-toggle" v-model="enabled" /> + <span class="mb-2 ml-1 lh-2" v-html="toggleDescriptionText"></span> + </div> + </gl-form-group> + + <gl-form-group + id="expiration-policy-interval-group" + :label-cols="$options.labelsConfig.cols" + :label-align="$options.labelsConfig.align" + label-for="expiration-policy-interval" + :label="s__('ContainerRegistry|Expiration interval:')" + > + <gl-form-select id="expiration-policy-interval" v-model="older_than" :disabled="!enabled"> + <option v-for="option in formOptions.olderThan" :key="option.key" :value="option.key"> + {{ option.label }} + </option> + </gl-form-select> + </gl-form-group> + + <gl-form-group + id="expiration-policy-schedule-group" + :label-cols="$options.labelsConfig.cols" + :label-align="$options.labelsConfig.align" + label-for="expiration-policy-schedule" + :label="s__('ContainerRegistry|Expiration schedule:')" + > + <gl-form-select id="expiration-policy-schedule" v-model="cadence" :disabled="!enabled"> + <option v-for="option in formOptions.cadence" :key="option.key" :value="option.key"> + {{ option.label }} + </option> + </gl-form-select> + </gl-form-group> + + <gl-form-group + id="expiration-policy-latest-group" + :label-cols="$options.labelsConfig.cols" + :label-align="$options.labelsConfig.align" + label-for="expiration-policy-latest" + :label="s__('ContainerRegistry|Number of tags to retain:')" + > + <gl-form-select id="expiration-policy-latest" v-model="keep_n" :disabled="!enabled"> + <option v-for="option in formOptions.keepN" :key="option.key" :value="option.key"> + {{ option.label }} + </option> + </gl-form-select> + </gl-form-group> + + <gl-form-group + id="expiration-policy-name-matching-group" + :label-cols="$options.labelsConfig.cols" + :label-align="$options.labelsConfig.align" + label-for="expiration-policy-name-matching" + :label="s__('ContainerRegistry|Expire Docker tags that match this regex:')" + :state="nameRegexState" + :invalid-feedback=" + s__('ContainerRegistry|The value of this input should be less than 255 characters') + " + > + <gl-form-textarea + id="expiration-policy-name-matching" + v-model="name_regex" + :placeholder="nameRegexPlaceholder" + :state="nameRegexState" + :disabled="!enabled" + trim + /> + <template #description> + <span ref="regex-description" v-html="regexHelpText"></span> + </template> + </gl-form-group> + </template> + <template #footer> + <div class="d-flex justify-content-end"> + <gl-button ref="cancel-button" type="reset" class="mr-2 d-block">{{ + __('Cancel') + }}</gl-button> + <gl-button + ref="save-button" + type="submit" + :disabled="formIsInvalid" + variant="success" + class="d-block" + > + {{ __('Save expiration policy') }} + </gl-button> + </div> + </template> + </gl-card> + </form> +</template> diff --git a/app/assets/javascripts/registry/settings/constants.js b/app/assets/javascripts/registry/settings/constants.js new file mode 100644 index 0000000000000000000000000000000000000000..c0dac466b29ec27102329cd04bd97cf334b9ed41 --- /dev/null +++ b/app/assets/javascripts/registry/settings/constants.js @@ -0,0 +1,15 @@ +import { s__ } from '~/locale'; + +export const FETCH_SETTINGS_ERROR_MESSAGE = s__( + 'ContainerRegistry|Something went wrong while fetching the expiration policy.', +); + +export const UPDATE_SETTINGS_ERROR_MESSAGE = s__( + 'ContainerRegistry|Something went wrong while updating the expiration policy.', +); + +export const UPDATE_SETTINGS_SUCCESS_MESSAGE = s__( + 'ContainerRegistry|Expiration policy successfully saved.', +); + +export const NAME_REGEX_LENGTH = 255; diff --git a/app/assets/javascripts/registry/settings/registry_settings_bundle.js b/app/assets/javascripts/registry/settings/registry_settings_bundle.js index 2938178ea864d0bffe6e20a7ce6f0eeb4954dcff..927b6059884b179fe1964d3a9c289af707165c60 100644 --- a/app/assets/javascripts/registry/settings/registry_settings_bundle.js +++ b/app/assets/javascripts/registry/settings/registry_settings_bundle.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import Translate from '~/vue_shared/translate'; -import store from './stores/'; +import store from './store/'; import RegistrySettingsApp from './components/registry_settings_app.vue'; Vue.use(Translate); diff --git a/app/assets/javascripts/registry/settings/store/actions.js b/app/assets/javascripts/registry/settings/store/actions.js new file mode 100644 index 0000000000000000000000000000000000000000..5e46d564121eb9f84e90f857f96b39ca73125a7d --- /dev/null +++ b/app/assets/javascripts/registry/settings/store/actions.js @@ -0,0 +1,42 @@ +import Api from '~/api'; +import createFlash from '~/flash'; +import { + FETCH_SETTINGS_ERROR_MESSAGE, + UPDATE_SETTINGS_ERROR_MESSAGE, + UPDATE_SETTINGS_SUCCESS_MESSAGE, +} from '../constants'; +import * as types from './mutation_types'; + +export const setInitialState = ({ commit }, data) => commit(types.SET_INITIAL_STATE, data); +export const updateSettings = ({ commit }, data) => commit(types.UPDATE_SETTINGS, data); +export const toggleLoading = ({ commit }) => commit(types.TOGGLE_LOADING); +export const receiveSettingsSuccess = ({ commit }, data = {}) => commit(types.SET_SETTINGS, data); +export const receiveSettingsError = () => createFlash(FETCH_SETTINGS_ERROR_MESSAGE); +export const updateSettingsError = () => createFlash(UPDATE_SETTINGS_ERROR_MESSAGE); +export const resetSettings = ({ commit }) => commit(types.RESET_SETTINGS); + +export const fetchSettings = ({ dispatch, state }) => { + dispatch('toggleLoading'); + return Api.project(state.projectId) + .then(({ data: { container_expiration_policy } }) => + dispatch('receiveSettingsSuccess', container_expiration_policy), + ) + .catch(() => dispatch('receiveSettingsError')) + .finally(() => dispatch('toggleLoading')); +}; + +export const saveSettings = ({ dispatch, state }) => { + dispatch('toggleLoading'); + return Api.updateProject(state.projectId, { + container_expiration_policy_attributes: state.settings, + }) + .then(({ data: { container_expiration_policy } }) => { + dispatch('receiveSettingsSuccess', container_expiration_policy); + createFlash(UPDATE_SETTINGS_SUCCESS_MESSAGE, 'success'); + }) + .catch(() => dispatch('updateSettingsError')) + .finally(() => dispatch('toggleLoading')); +}; + +// prevent babel-plugin-rewire from generating an invalid default during karma tests +export default () => {}; diff --git a/app/assets/javascripts/registry/settings/store/getters.js b/app/assets/javascripts/registry/settings/store/getters.js new file mode 100644 index 0000000000000000000000000000000000000000..fc32a9f08e4b3b922de3e3c19df87f25458cb2f7 --- /dev/null +++ b/app/assets/javascripts/registry/settings/store/getters.js @@ -0,0 +1,8 @@ +import { findDefaultOption } from '../utils'; + +export const getCadence = state => + state.settings.cadence || findDefaultOption(state.formOptions.cadence); +export const getKeepN = state => + state.settings.keep_n || findDefaultOption(state.formOptions.keepN); +export const getOlderThan = state => + state.settings.older_than || findDefaultOption(state.formOptions.olderThan); diff --git a/app/assets/javascripts/registry/settings/stores/index.js b/app/assets/javascripts/registry/settings/store/index.js similarity index 85% rename from app/assets/javascripts/registry/settings/stores/index.js rename to app/assets/javascripts/registry/settings/store/index.js index 91a35aac149c7b24450342f3e44b80df5d1c5ee2..c2500454d8eb739437f70c9f1ac0ee192c543b76 100644 --- a/app/assets/javascripts/registry/settings/stores/index.js +++ b/app/assets/javascripts/registry/settings/store/index.js @@ -2,6 +2,7 @@ import Vue from 'vue'; import Vuex from 'vuex'; import * as actions from './actions'; import mutations from './mutations'; +import * as getters from './getters'; import state from './state'; Vue.use(Vuex); @@ -11,6 +12,7 @@ export const createStore = () => state, actions, mutations, + getters, }); export default createStore(); diff --git a/app/assets/javascripts/registry/settings/store/mutation_types.js b/app/assets/javascripts/registry/settings/store/mutation_types.js new file mode 100644 index 0000000000000000000000000000000000000000..db499ffa7614e4deb1b5b748e0b0266fe19741fb --- /dev/null +++ b/app/assets/javascripts/registry/settings/store/mutation_types.js @@ -0,0 +1,5 @@ +export const SET_INITIAL_STATE = 'SET_INITIAL_STATE'; +export const UPDATE_SETTINGS = 'UPDATE_SETTINGS'; +export const TOGGLE_LOADING = 'TOGGLE_LOADING'; +export const SET_SETTINGS = 'SET_SETTINGS'; +export const RESET_SETTINGS = 'RESET_SETTINGS'; diff --git a/app/assets/javascripts/registry/settings/store/mutations.js b/app/assets/javascripts/registry/settings/store/mutations.js new file mode 100644 index 0000000000000000000000000000000000000000..25a67cc69734cf3a167e93366cccf22d8719b2c4 --- /dev/null +++ b/app/assets/javascripts/registry/settings/store/mutations.js @@ -0,0 +1,25 @@ +import * as types from './mutation_types'; + +export default { + [types.SET_INITIAL_STATE](state, initialState) { + state.projectId = initialState.projectId; + state.formOptions = { + cadence: JSON.parse(initialState.cadenceOptions), + keepN: JSON.parse(initialState.keepNOptions), + olderThan: JSON.parse(initialState.olderThanOptions), + }; + }, + [types.UPDATE_SETTINGS](state, settings) { + state.settings = { ...state.settings, ...settings }; + }, + [types.SET_SETTINGS](state, settings) { + state.settings = settings; + state.original = Object.freeze(settings); + }, + [types.RESET_SETTINGS](state) { + state.settings = { ...state.original }; + }, + [types.TOGGLE_LOADING](state) { + state.isLoading = !state.isLoading; + }, +}; diff --git a/app/assets/javascripts/registry/settings/store/state.js b/app/assets/javascripts/registry/settings/store/state.js new file mode 100644 index 0000000000000000000000000000000000000000..50c882e1839d3c8be4d717a7b3fdaf7741cad4a9 --- /dev/null +++ b/app/assets/javascripts/registry/settings/store/state.js @@ -0,0 +1,30 @@ +export default () => ({ + /* + * Project Id used to build the API call + */ + projectId: '', + /* + * Boolean to determine if the UI is loading data from the API + */ + isLoading: false, + /* + * This contains the data shown and manipulated in the UI + * Has the following structure: + * { + * enabled: Boolean + * cadence: String, + * older_than: String, + * keep_n: String, + * name_regex: String + * } + */ + settings: {}, + /* + * Same structure as settings, above but Frozen object and used only in case the user clicks 'cancel' + */ + original: {}, + /* + * Contains the options used to populate the form selects + */ + formOptions: {}, +}); diff --git a/app/assets/javascripts/registry/settings/stores/actions.js b/app/assets/javascripts/registry/settings/stores/actions.js deleted file mode 100644 index f2c469d4edb3355b536b5a1e374707aac8ac32cf..0000000000000000000000000000000000000000 --- a/app/assets/javascripts/registry/settings/stores/actions.js +++ /dev/null @@ -1,6 +0,0 @@ -import * as types from './mutation_types'; - -export const setInitialState = ({ commit }, data) => commit(types.SET_INITIAL_STATE, data); - -// to avoid eslint error until more actions are added to the store -export default () => {}; diff --git a/app/assets/javascripts/registry/settings/stores/mutation_types.js b/app/assets/javascripts/registry/settings/stores/mutation_types.js deleted file mode 100644 index 8a0f519eabdd6d449f2a201616cd5b7f992ff50f..0000000000000000000000000000000000000000 --- a/app/assets/javascripts/registry/settings/stores/mutation_types.js +++ /dev/null @@ -1,4 +0,0 @@ -export const SET_INITIAL_STATE = 'SET_INITIAL_STATE'; - -// to avoid eslint error until more actions are added to the store -export default () => {}; diff --git a/app/assets/javascripts/registry/settings/stores/mutations.js b/app/assets/javascripts/registry/settings/stores/mutations.js deleted file mode 100644 index 4f32e11ed523be2562705651e2ec51223e228217..0000000000000000000000000000000000000000 --- a/app/assets/javascripts/registry/settings/stores/mutations.js +++ /dev/null @@ -1,8 +0,0 @@ -import * as types from './mutation_types'; - -export default { - [types.SET_INITIAL_STATE](state, initialState) { - state.helpPagePath = initialState.helpPagePath; - state.registrySettingsEndpoint = initialState.registrySettingsEndpoint; - }, -}; diff --git a/app/assets/javascripts/registry/settings/stores/state.js b/app/assets/javascripts/registry/settings/stores/state.js deleted file mode 100644 index 4c0439458b688fca6e17a19f3baa676d78cce8fb..0000000000000000000000000000000000000000 --- a/app/assets/javascripts/registry/settings/stores/state.js +++ /dev/null @@ -1,10 +0,0 @@ -export default () => ({ - /* - * Help page path to generate the link - */ - helpPagePath: '', - /* - * Settings endpoint to call to fetch and update the settings - */ - registrySettingsEndpoint: '', -}); diff --git a/app/assets/javascripts/registry/settings/utils.js b/app/assets/javascripts/registry/settings/utils.js new file mode 100644 index 0000000000000000000000000000000000000000..75af401e96d2350483a58c73028c8e25e588e7f3 --- /dev/null +++ b/app/assets/javascripts/registry/settings/utils.js @@ -0,0 +1,6 @@ +export const findDefaultOption = options => { + const item = options.find(o => o.default); + return item ? item.key : null; +}; + +export default () => {}; diff --git a/app/assets/javascripts/releases/list/components/app.vue b/app/assets/javascripts/releases/list/components/app.vue index a414b3ccd4ec09e2c7761a100afd7637ff5acb41..eb63e709ebdd5588c557afdae2ee518d4318a45a 100644 --- a/app/assets/javascripts/releases/list/components/app.vue +++ b/app/assets/javascripts/releases/list/components/app.vue @@ -66,7 +66,7 @@ export default { :svg-path="illustrationPath" :description=" __( - 'Releases mark specific points in a project\'s development history, communicate information about the type of change, and deliver on prepared, often compiled, versions of the software to be reused elsewhere. Currently, releases can only be created through the API.', + 'Releases are based on Git tags and mark specific points in a project\'s development history. They can contain information about the type of changes and can also deliver binaries, like compiled versions of your software.', ) " :primary-button-link="documentationLink" diff --git a/app/assets/javascripts/releases/list/components/release_block.vue b/app/assets/javascripts/releases/list/components/release_block.vue index 4d8d8682401de695d1d79484d3dd8d578aae7b23..d924b5795f0aea24f02671f5a96b8f161ad33221 100644 --- a/app/assets/javascripts/releases/list/components/release_block.vue +++ b/app/assets/javascripts/releases/list/components/release_block.vue @@ -1,35 +1,27 @@ <script> -/* eslint-disable @gitlab/vue-i18n/no-bare-strings */ import _ from 'underscore'; -import { GlTooltipDirective, GlLink, GlBadge, GlButton } from '@gitlab/ui'; -import Icon from '~/vue_shared/components/icon.vue'; -import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; -import timeagoMixin from '~/vue_shared/mixins/timeago'; -import { __, n__, sprintf } from '~/locale'; import { slugify } from '~/lib/utils/text_utility'; import { getLocationHash } from '~/lib/utils/url_utility'; import { scrollToElement } from '~/lib/utils/common_utils'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import ReleaseBlockFooter from './release_block_footer.vue'; import EvidenceBlock from './evidence_block.vue'; +import ReleaseBlockAssets from './release_block_assets.vue'; +import ReleaseBlockFooter from './release_block_footer.vue'; +import ReleaseBlockHeader from './release_block_header.vue'; +import ReleaseBlockMetadata from './release_block_metadata.vue'; import ReleaseBlockMilestoneInfo from './release_block_milestone_info.vue'; export default { name: 'ReleaseBlock', components: { EvidenceBlock, - GlLink, - GlBadge, - GlButton, - Icon, - UserAvatarLink, + ReleaseBlockAssets, ReleaseBlockFooter, + ReleaseBlockHeader, + ReleaseBlockMetadata, ReleaseBlockMilestoneInfo, }, - directives: { - GlTooltip: GlTooltipDirective, - }, - mixins: [timeagoMixin, glFeatureFlagsMixin()], + mixins: [glFeatureFlagsMixin()], props: { release: { type: Object, @@ -46,45 +38,14 @@ export default { id() { return slugify(this.release.tag_name); }, - releasedTimeAgo() { - return sprintf(__('released %{time}'), { - time: this.timeFormatted(this.release.released_at), - }); - }, - userImageAltDescription() { - return this.author && this.author.username - ? sprintf(__("%{username}'s avatar"), { username: this.author.username }) - : null; - }, - commit() { - return this.release.commit || {}; - }, - commitUrl() { - return this.release.commit_path; - }, - tagUrl() { - return this.release.tag_path; - }, assets() { return this.release.assets || {}; }, - author() { - return this.release.author || {}; - }, - hasAuthor() { - return !_.isEmpty(this.author); - }, hasEvidence() { return Boolean(this.release.evidence_sha); }, - shouldRenderMilestones() { - return !_.isEmpty(this.release.milestones); - }, - labelText() { - return n__('Milestone', 'Milestones', this.release.milestones.length); - }, - shouldShowEditButton() { - return Boolean(this.release._links && this.release._links.edit_url); + milestones() { + return this.release.milestones || []; }, shouldShowEvidence() { return this.glFeatures.releaseEvidenceCollection; @@ -92,6 +53,11 @@ export default { shouldShowFooter() { return this.glFeatures.releaseIssueSummary; }, + shouldRenderAssets() { + return Boolean( + this.assets.links.length || (this.assets.sources && this.assets.sources.length), + ); + }, shouldRenderReleaseMetaData() { return !this.glFeatures.releaseIssueSummary; }, @@ -114,127 +80,15 @@ export default { </script> <template> <div :id="id" :class="{ 'bg-line-target-blue': isHighlighted }" class="card release-block"> - <div class="card-header d-flex align-items-center bg-white pr-0"> - <h2 class="card-title my-2 mr-auto gl-font-size-20"> - {{ release.name }} - <gl-badge v-if="release.upcoming_release" variant="warning" class="align-middle">{{ - __('Upcoming Release') - }}</gl-badge> - </h2> - <gl-link - v-if="shouldShowEditButton" - v-gl-tooltip - class="btn btn-default append-right-10 js-edit-button ml-2" - :title="__('Edit this release')" - :href="release._links.edit_url" - > - <icon name="pencil" /> - </gl-link> - </div> + <release-block-header :release="release" /> <div class="card-body"> <div v-if="shouldRenderMilestoneInfo"> - <release-block-milestone-info :milestones="release.milestones" /> + <release-block-milestone-info :milestones="milestones" /> <hr class="mb-3 mt-0" /> </div> - <div v-if="shouldRenderReleaseMetaData" class="card-subtitle d-flex flex-wrap text-secondary"> - <div class="append-right-8"> - <icon name="commit" class="align-middle" /> - <gl-link v-if="commitUrl" v-gl-tooltip.bottom :title="commit.title" :href="commitUrl"> - {{ commit.short_id }} - </gl-link> - <span v-else v-gl-tooltip.bottom :title="commit.title">{{ commit.short_id }}</span> - </div> - - <div class="append-right-8"> - <icon name="tag" class="align-middle" /> - <gl-link v-if="tagUrl" v-gl-tooltip.bottom :title="__('Tag')" :href="tagUrl"> - {{ release.tag_name }} - </gl-link> - <span v-else v-gl-tooltip.bottom :title="__('Tag')">{{ release.tag_name }}</span> - </div> - - <template v-if="shouldRenderMilestones"> - <div class="js-milestone-list-label"> - <icon name="flag" class="align-middle" /> - <span class="js-label-text">{{ labelText }}</span> - </div> - - <template v-for="(milestone, index) in release.milestones"> - <gl-link - :key="milestone.id" - v-gl-tooltip - :title="milestone.description" - :href="milestone.web_url" - class="append-right-4 prepend-left-4 js-milestone-link" - > - {{ milestone.title }} - </gl-link> - <template v-if="index !== release.milestones.length - 1"> - • - </template> - </template> - </template> - - <div class="append-right-4"> - • - <span v-gl-tooltip.bottom :title="tooltipTitle(release.released_at)"> - {{ releasedTimeAgo }} - </span> - </div> - - <div v-if="hasAuthor" class="d-flex"> - by - <user-avatar-link - class="prepend-left-4" - :link-href="author.web_url" - :img-src="author.avatar_url" - :img-alt="userImageAltDescription" - :tooltip-text="author.username" - /> - </div> - </div> - - <div - v-if="assets.links.length || (assets.sources && assets.sources.length)" - class="card-text prepend-top-default" - > - <b> - {{ __('Assets') }} - <span class="js-assets-count badge badge-pill">{{ assets.count }}</span> - </b> - - <ul v-if="assets.links.length" class="pl-0 mb-0 prepend-top-8 list-unstyled js-assets-list"> - <li v-for="link in assets.links" :key="link.name" class="append-bottom-8"> - <gl-link v-gl-tooltip.bottom :title="__('Download asset')" :href="link.url"> - <icon name="package" class="align-middle append-right-4 align-text-bottom" /> - {{ link.name }} - <span v-if="link.external">{{ __('(external source)') }}</span> - </gl-link> - </li> - </ul> - - <div v-if="assets.sources && assets.sources.length" class="dropdown"> - <button - type="button" - class="btn btn-link" - data-toggle="dropdown" - aria-haspopup="true" - aria-expanded="false" - > - <icon name="doc-code" class="align-top append-right-4" /> - {{ __('Source code') }} - <icon name="arrow-down" /> - </button> - - <div class="js-sources-dropdown dropdown-menu"> - <li v-for="asset in assets.sources" :key="asset.url"> - <gl-link :href="asset.url">{{ __('Download') }} {{ asset.format }}</gl-link> - </li> - </div> - </div> - </div> - + <release-block-metadata v-if="shouldRenderReleaseMetaData" :release="release" /> + <release-block-assets v-if="shouldRenderAssets" :assets="assets" /> <evidence-block v-if="hasEvidence && shouldShowEvidence" :release="release" /> <div class="card-text prepend-top-default"> diff --git a/app/assets/javascripts/releases/list/components/release_block_assets.vue b/app/assets/javascripts/releases/list/components/release_block_assets.vue new file mode 100644 index 0000000000000000000000000000000000000000..e840bc90d68044a622901ab638f694b48f388ec4 --- /dev/null +++ b/app/assets/javascripts/releases/list/components/release_block_assets.vue @@ -0,0 +1,65 @@ +<script> +import { GlTooltipDirective, GlLink } from '@gitlab/ui'; +import Icon from '~/vue_shared/components/icon.vue'; + +export default { + name: 'ReleaseBlockAssets', + components: { + GlLink, + Icon, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + assets: { + type: Object, + required: true, + }, + }, + computed: { + hasAssets() { + return Boolean(this.assets.count); + }, + }, +}; +</script> + +<template> + <div class="card-text prepend-top-default"> + <b> + {{ __('Assets') }} + <span class="js-assets-count badge badge-pill">{{ assets.count }}</span> + </b> + + <ul v-if="assets.links.length" class="pl-0 mb-0 prepend-top-8 list-unstyled js-assets-list"> + <li v-for="link in assets.links" :key="link.name" class="append-bottom-8"> + <gl-link v-gl-tooltip.bottom :title="__('Download asset')" :href="link.url"> + <icon name="package" class="align-middle append-right-4 align-text-bottom" /> + {{ link.name }} + <span v-if="link.external">{{ __('(external source)') }}</span> + </gl-link> + </li> + </ul> + + <div v-if="hasAssets" class="dropdown"> + <button + type="button" + class="btn btn-link" + data-toggle="dropdown" + aria-haspopup="true" + aria-expanded="false" + > + <icon name="doc-code" class="align-top append-right-4" /> + {{ __('Source code') }} + <icon name="arrow-down" /> + </button> + + <div class="js-sources-dropdown dropdown-menu"> + <li v-for="asset in assets.sources" :key="asset.url"> + <gl-link :href="asset.url">{{ __('Download') }} {{ asset.format }}</gl-link> + </li> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/releases/list/components/release_block_author.vue b/app/assets/javascripts/releases/list/components/release_block_author.vue new file mode 100644 index 0000000000000000000000000000000000000000..ff6b00d8221921b7e03759d41fb1d593bc283104 --- /dev/null +++ b/app/assets/javascripts/releases/list/components/release_block_author.vue @@ -0,0 +1,42 @@ +<script> +import { __, sprintf } from '~/locale'; +import { GlSprintf } from '@gitlab/ui'; +import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; + +export default { + name: 'ReleaseBlockAuthor', + components: { + GlSprintf, + UserAvatarLink, + }, + props: { + author: { + type: Object, + required: true, + }, + }, + computed: { + userImageAltDescription() { + return this.author && this.author.username + ? sprintf(__("%{username}'s avatar"), { username: this.author.username }) + : null; + }, + }, +}; +</script> + +<template> + <div class="d-flex"> + <gl-sprintf message="by %{user}"> + <template #user> + <user-avatar-link + class="prepend-left-4" + :link-href="author.web_url" + :img-src="author.avatar_url" + :img-alt="userImageAltDescription" + :tooltip-text="author.username" + /> + </template> + </gl-sprintf> + </div> +</template> diff --git a/app/assets/javascripts/releases/list/components/release_block_header.vue b/app/assets/javascripts/releases/list/components/release_block_header.vue new file mode 100644 index 0000000000000000000000000000000000000000..9c5dcf2a70990850e67a6242c85a00defbfcf167 --- /dev/null +++ b/app/assets/javascripts/releases/list/components/release_block_header.vue @@ -0,0 +1,47 @@ +<script> +import { GlTooltipDirective, GlLink, GlBadge } from '@gitlab/ui'; +import Icon from '~/vue_shared/components/icon.vue'; + +export default { + name: 'ReleaseBlockHeader', + components: { + GlLink, + GlBadge, + Icon, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + release: { + type: Object, + required: true, + }, + }, + computed: { + shouldShowEditButton() { + return Boolean(this.release._links && this.release._links.edit_url); + }, + }, +}; +</script> + +<template> + <div class="card-header d-flex align-items-center bg-white pr-0"> + <h2 class="card-title my-2 mr-auto gl-font-size-20"> + {{ release.name }} + <gl-badge v-if="release.upcoming_release" variant="warning" class="align-middle">{{ + __('Upcoming Release') + }}</gl-badge> + </h2> + <gl-link + v-if="shouldShowEditButton" + v-gl-tooltip + class="btn btn-default append-right-10 js-edit-button ml-2" + :title="__('Edit this release')" + :href="release._links.edit_url" + > + <icon name="pencil" /> + </gl-link> + </div> +</template> diff --git a/app/assets/javascripts/releases/list/components/release_block_metadata.vue b/app/assets/javascripts/releases/list/components/release_block_metadata.vue new file mode 100644 index 0000000000000000000000000000000000000000..f0aad5940629a3a019b71128d9020111ce2796af --- /dev/null +++ b/app/assets/javascripts/releases/list/components/release_block_metadata.vue @@ -0,0 +1,84 @@ +<script> +import { GlTooltipDirective, GlLink } from '@gitlab/ui'; +import { __, sprintf } from '~/locale'; +import Icon from '~/vue_shared/components/icon.vue'; +import timeagoMixin from '~/vue_shared/mixins/timeago'; +import ReleaseBlockAuthor from './release_block_author.vue'; +import ReleaseBlockMilestones from './release_block_milestones.vue'; + +export default { + name: 'ReleaseBlockMetadata', + components: { + Icon, + GlLink, + ReleaseBlockAuthor, + ReleaseBlockMilestones, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + mixins: [timeagoMixin], + props: { + release: { + type: Object, + required: true, + }, + }, + computed: { + author() { + return this.release.author; + }, + commit() { + return this.release.commit || {}; + }, + commitUrl() { + return this.release.commit_path; + }, + hasAuthor() { + return Boolean(this.author); + }, + releasedTimeAgo() { + return sprintf(__('released %{time}'), { + time: this.timeFormatted(this.release.released_at), + }); + }, + shouldRenderMilestones() { + return Boolean(this.release.milestones?.length); + }, + tagUrl() { + return this.release.tag_path; + }, + }, +}; +</script> + +<template> + <div class="card-subtitle d-flex flex-wrap text-secondary"> + <div class="append-right-8"> + <icon name="commit" class="align-middle" /> + <gl-link v-if="commitUrl" v-gl-tooltip.bottom :title="commit.title" :href="commitUrl"> + {{ commit.short_id }} + </gl-link> + <span v-else v-gl-tooltip.bottom :title="commit.title">{{ commit.short_id }}</span> + </div> + + <div class="append-right-8"> + <icon name="tag" class="align-middle" /> + <gl-link v-if="tagUrl" v-gl-tooltip.bottom :title="__('Tag')" :href="tagUrl"> + {{ release.tag_name }} + </gl-link> + <span v-else v-gl-tooltip.bottom :title="__('Tag')">{{ release.tag_name }}</span> + </div> + + <release-block-milestones v-if="shouldRenderMilestones" :milestones="release.milestones" /> + + <div class="append-right-4"> + • + <span v-gl-tooltip.bottom :title="tooltipTitle(release.released_at)"> + {{ releasedTimeAgo }} + </span> + </div> + + <release-block-author v-if="hasAuthor" :author="author" /> + </div> +</template> diff --git a/app/assets/javascripts/releases/list/components/release_block_milestones.vue b/app/assets/javascripts/releases/list/components/release_block_milestones.vue new file mode 100644 index 0000000000000000000000000000000000000000..a3dff75b8288f856c08571044cfbc75c8265c251 --- /dev/null +++ b/app/assets/javascripts/releases/list/components/release_block_milestones.vue @@ -0,0 +1,51 @@ +<script> +import { GlTooltipDirective, GlLink } from '@gitlab/ui'; +import { n__ } from '~/locale'; +import Icon from '~/vue_shared/components/icon.vue'; + +export default { + name: 'ReleaseBlockMilestones', + components: { + GlLink, + Icon, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + milestones: { + type: Array, + required: true, + }, + }, + computed: { + labelText() { + return n__('Milestone', 'Milestones', this.milestones.length); + }, + }, +}; +</script> + +<template> + <div> + <div class="js-milestone-list-label"> + <icon name="flag" class="align-middle" /> + <span class="js-label-text">{{ labelText }}</span> + </div> + + <template v-for="(milestone, index) in milestones"> + <gl-link + :key="milestone.id" + v-gl-tooltip + :title="milestone.description" + :href="milestone.web_url" + class="mx-1 js-milestone-link" + > + {{ milestone.title }} + </gl-link> + <template v-if="index !== milestones.length - 1"> + • + </template> + </template> + </div> +</template> diff --git a/app/assets/javascripts/reports/components/modal.vue b/app/assets/javascripts/reports/components/modal.vue index 6019af2dfe0bb0a08e56e68f7c7c9fdbff64f1e0..40ce200befb750b5e467e2a67bdcd0f3f278f611 100644 --- a/app/assets/javascripts/reports/components/modal.vue +++ b/app/assets/javascripts/reports/components/modal.vue @@ -1,14 +1,12 @@ <script> // import { sprintf, __ } from '~/locale'; import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue'; -import LoadingButton from '~/vue_shared/components/loading_button.vue'; import CodeBlock from '~/vue_shared/components/code_block.vue'; import { fieldTypes } from '../constants'; export default { components: { Modal: DeprecatedModal2, - LoadingButton, CodeBlock, }, props: { diff --git a/app/assets/javascripts/reports/components/report_section.vue b/app/assets/javascripts/reports/components/report_section.vue index 45c890769a0fd644d46861e2b6e3b85ba1681e7d..20b0c52dbda145046e64b549a23748f7013fb6f1 100644 --- a/app/assets/javascripts/reports/components/report_section.vue +++ b/app/assets/javascripts/reports/components/report_section.vue @@ -165,21 +165,23 @@ export default { <template> <section class="media-section"> <div class="media"> - <status-icon :status="statusIconName" :size="24" /> - <div class="media-body d-flex flex-align-self-center"> - <span class="js-code-text code-text"> - {{ headerText }} - <slot :name="slotName"></slot> - - <popover v-if="hasPopover" :options="popoverOptions" class="prepend-left-5" /> - </span> + <status-icon :status="statusIconName" :size="24" class="align-self-center" /> + <div class="media-body d-flex flex-align-self-center align-items-center"> + <div class="js-code-text code-text"> + <div> + {{ headerText }} + <slot :name="slotName"></slot> + <popover v-if="hasPopover" :options="popoverOptions" class="prepend-left-5" /> + </div> + <slot name="subHeading"></slot> + </div> <slot name="actionButtons"></slot> <button v-if="isCollapsible" type="button" - class="js-collapse-btn btn float-right btn-sm align-self-start qa-expand-report-button" + class="js-collapse-btn btn float-right btn-sm align-self-center qa-expand-report-button" @click="toggleCollapsed" > {{ collapseText }} diff --git a/app/assets/javascripts/repository/components/last_commit.vue b/app/assets/javascripts/repository/components/last_commit.vue index 70678b0db37f390fb994feea011eacaaff423d5e..fe1724acf8991f97d8fb78c9fba8c4c00e526144 100644 --- a/app/assets/javascripts/repository/components/last_commit.vue +++ b/app/assets/javascripts/repository/components/last_commit.vue @@ -90,7 +90,7 @@ export default { <template> <div class="info-well d-none d-sm-flex project-last-commit commit p-3"> - <gl-loading-icon v-if="isLoading" size="md" class="m-auto" /> + <gl-loading-icon v-if="isLoading" size="md" color="dark" class="m-auto" /> <template v-else> <user-avatar-link v-if="commit.author" @@ -104,7 +104,11 @@ export default { </span> <div class="commit-detail flex-list"> <div class="commit-content qa-commit-content"> - <gl-link :href="commit.webUrl" class="commit-row-message item-title"> + <gl-link + :href="commit.webUrl" + :class="{ 'font-italic': !commit.message }" + class="commit-row-message item-title" + > {{ commit.title }} </gl-link> <gl-button diff --git a/app/assets/javascripts/repository/components/preview/index.vue b/app/assets/javascripts/repository/components/preview/index.vue index 6b3822151ff7ae25f107d4490e3dd5fd55d61049..2bc93c3f1c1defdfc183f9b017b70565d8da8517 100644 --- a/app/assets/javascripts/repository/components/preview/index.vue +++ b/app/assets/javascripts/repository/components/preview/index.vue @@ -44,7 +44,7 @@ export default { </div> </div> <div class="blob-viewer"> - <gl-loading-icon v-if="loading > 0" size="md" class="my-4 mx-auto" /> + <gl-loading-icon v-if="loading > 0" size="md" color="dark" class="my-4 mx-auto" /> <div v-else-if="readme" v-html="readme.html"></div> </div> </article> diff --git a/app/assets/javascripts/repository/components/table/index.vue b/app/assets/javascripts/repository/components/table/index.vue index 8f2e9264bcab1166242d5c741f021e48a3a38357..29a3340b83d5426a284dd90804534f722ca551e3 100644 --- a/app/assets/javascripts/repository/components/table/index.vue +++ b/app/assets/javascripts/repository/components/table/index.vue @@ -34,6 +34,11 @@ export default { type: Boolean, required: true, }, + loadingPath: { + type: String, + required: false, + default: '', + }, }, data() { return { @@ -69,7 +74,12 @@ export default { <table :aria-label="tableCaption" class="table tree-table qa-file-tree" aria-live="polite"> <table-header v-once /> <tbody> - <parent-row v-show="showParentRow" :commit-ref="ref" :path="path" /> + <parent-row + v-show="showParentRow" + :commit-ref="ref" + :path="path" + :loading-path="loadingPath" + /> <template v-for="val in entries"> <table-row v-for="entry in val" @@ -84,6 +94,7 @@ export default { :url="entry.webUrl" :submodule-tree-url="entry.treeUrl" :lfs-oid="entry.lfsOid" + :loading-path="loadingPath" /> </template> <template v-if="isLoading"> diff --git a/app/assets/javascripts/repository/components/table/parent_row.vue b/app/assets/javascripts/repository/components/table/parent_row.vue index 3c39f4042266fd3b4c9e7889005c3a7aa375a1f5..70a188f98cc7946100dcb2e6a8711cc3c6c6bc40 100644 --- a/app/assets/javascripts/repository/components/table/parent_row.vue +++ b/app/assets/javascripts/repository/components/table/parent_row.vue @@ -1,5 +1,10 @@ <script> +import { GlLoadingIcon } from '@gitlab/ui'; + export default { + components: { + GlLoadingIcon, + }, props: { commitRef: { type: String, @@ -9,13 +14,21 @@ export default { type: String, required: true, }, + loadingPath: { + type: String, + required: false, + default: null, + }, }, computed: { - parentRoute() { + parentPath() { const splitArray = this.path.split('/'); splitArray.pop(); - return { path: `/tree/${this.commitRef}/${splitArray.join('/')}` }; + return splitArray.join('/'); + }, + parentRoute() { + return { path: `/tree/${this.commitRef}/${this.parentPath}` }; }, }, methods: { @@ -29,7 +42,13 @@ export default { <template> <tr class="tree-item"> <td colspan="3" class="tree-item-file-name" @click.self="clickRow"> - <router-link :to="parentRoute" :aria-label="__('Go to parent')"> + <gl-loading-icon + v-if="parentPath === loadingPath" + size="sm" + inline + class="d-inline-block align-text-bottom" + /> + <router-link v-else :to="parentRoute" :aria-label="__('Go to parent')"> .. </router-link> </td> diff --git a/app/assets/javascripts/repository/components/table/row.vue b/app/assets/javascripts/repository/components/table/row.vue index cf0457a2abfbb4e7ebb2ae27de10ca891ff7dae7..a8e13241c3724a1e2fab78936efe07954a6db633 100644 --- a/app/assets/javascripts/repository/components/table/row.vue +++ b/app/assets/javascripts/repository/components/table/row.vue @@ -1,5 +1,5 @@ <script> -import { GlBadge, GlLink, GlSkeletonLoading, GlTooltipDirective } from '@gitlab/ui'; +import { GlBadge, GlLink, GlSkeletonLoading, GlTooltipDirective, GlLoadingIcon } from '@gitlab/ui'; import { visitUrl } from '~/lib/utils/url_utility'; import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import Icon from '~/vue_shared/components/icon.vue'; @@ -12,6 +12,7 @@ export default { GlBadge, GlLink, GlSkeletonLoading, + GlLoadingIcon, TimeagoTooltip, Icon, }, @@ -76,6 +77,11 @@ export default { required: false, default: null, }, + loadingPath: { + type: String, + required: false, + default: '', + }, }, data() { return { @@ -125,7 +131,13 @@ export default { <template> <tr :class="`file_${id}`" class="tree-item" @click="openRow"> <td class="tree-item-file-name"> - <i :aria-label="type" role="img" :class="iconName" class="fa fa-fw"></i> + <gl-loading-icon + v-if="path === loadingPath" + size="sm" + inline + class="d-inline-block align-text-bottom fa-fw" + /> + <i v-else :aria-label="type" role="img" :class="iconName" class="fa fa-fw"></i> <component :is="linkComponent" :to="routerLinkTo" :href="url" class="str-truncated"> {{ fullPath }} </component> diff --git a/app/assets/javascripts/repository/components/tree_content.vue b/app/assets/javascripts/repository/components/tree_content.vue index 949e653fc8f38c4123685adfd408cfbac33c5732..92e33b013c3c1c0c7808e81f690318240da39196 100644 --- a/app/assets/javascripts/repository/components/tree_content.vue +++ b/app/assets/javascripts/repository/components/tree_content.vue @@ -5,6 +5,7 @@ import FileTable from './table/index.vue'; import getRefMixin from '../mixins/get_ref'; import getFiles from '../queries/getFiles.query.graphql'; import getProjectPath from '../queries/getProjectPath.query.graphql'; +import getVueFileListLfsBadge from '../queries/getVueFileListLfsBadge.query.graphql'; import FilePreview from './preview/index.vue'; import { readmeFile } from '../utils/readme'; @@ -20,6 +21,9 @@ export default { projectPath: { query: getProjectPath, }, + vueFileListLfsBadge: { + query: getVueFileListLfsBadge, + }, }, props: { path: { @@ -27,6 +31,11 @@ export default { required: false, default: '/', }, + loadingPath: { + type: String, + required: false, + default: '', + }, }, data() { return { @@ -38,6 +47,7 @@ export default { blobs: [], }, isLoadingFiles: false, + vueFileListLfsBadge: false, }; }, computed: { @@ -72,6 +82,7 @@ export default { path: this.path || '/', nextPageCursor: this.nextPageCursor, pageSize: PAGE_SIZE, + vueLfsEnabled: this.vueFileListLfsBadge, }, }) .then(({ data }) => { @@ -109,7 +120,12 @@ export default { <template> <div> - <file-table :path="path" :entries="entries" :is-loading="isLoadingFiles" /> + <file-table + :path="path" + :entries="entries" + :is-loading="isLoadingFiles" + :loading-path="loadingPath" + /> <file-preview v-if="readme" :blob="readme" /> </div> </template> diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js index ae6409a0ac93064dda79230d71249aa079ce1a94..2ef0c078f1326809496d2fa29dda13d50924116a 100644 --- a/app/assets/javascripts/repository/index.js +++ b/app/assets/javascripts/repository/index.js @@ -23,6 +23,7 @@ export default function setupVueRepositoryList() { projectPath, projectShortPath, ref, + vueFileListLfsBadge: gon?.features?.vueFileListLfsBadge, commits: [], }, }); diff --git a/app/assets/javascripts/repository/mixins/preload.js b/app/assets/javascripts/repository/mixins/preload.js new file mode 100644 index 0000000000000000000000000000000000000000..e68996245a89475e96abb2ae8cb921de67f8eaaa --- /dev/null +++ b/app/assets/javascripts/repository/mixins/preload.js @@ -0,0 +1,36 @@ +import getFiles from '../queries/getFiles.query.graphql'; +import getRefMixin from './get_ref'; +import getProjectPath from '../queries/getProjectPath.query.graphql'; + +export default { + mixins: [getRefMixin], + apollo: { + projectPath: { + query: getProjectPath, + }, + }, + data() { + return { projectPath: '', loadingPath: null }; + }, + beforeRouteUpdate(to, from, next) { + this.preload(to.params.pathMatch, next); + }, + methods: { + preload(path, next) { + this.loadingPath = path.replace(/^\//, ''); + + return this.$apollo + .query({ + query: getFiles, + variables: { + projectPath: this.projectPath, + ref: this.ref, + path: this.loadingPath, + nextPageCursor: '', + pageSize: 100, + }, + }) + .then(() => next()); + }, + }, +}; diff --git a/app/assets/javascripts/repository/pages/tree.vue b/app/assets/javascripts/repository/pages/tree.vue index dd4d437f4ddf9ccb074bd5c905945a348ed59f9e..adc332fa370ecff7f51f370c479cc6226c38b027 100644 --- a/app/assets/javascripts/repository/pages/tree.vue +++ b/app/assets/javascripts/repository/pages/tree.vue @@ -1,11 +1,13 @@ <script> import TreeContent from '../components/tree_content.vue'; import { updateElementsVisibility } from '../utils/dom'; +import preloadMixin from '../mixins/preload'; export default { components: { TreeContent, }, + mixins: [preloadMixin], props: { path: { type: String, @@ -34,5 +36,5 @@ export default { </script> <template> - <tree-content :path="path" /> + <tree-content :path="path" :loading-path="loadingPath" /> </template> diff --git a/app/assets/javascripts/repository/queries/getFiles.query.graphql b/app/assets/javascripts/repository/queries/getFiles.query.graphql index 2aaf5066b4ac07ffaac866e5b814414bf9924e03..01ad72ef752a27e0745869fc026a1673c660a3e6 100644 --- a/app/assets/javascripts/repository/queries/getFiles.query.graphql +++ b/app/assets/javascripts/repository/queries/getFiles.query.graphql @@ -14,6 +14,7 @@ query getFiles( $ref: String! $pageSize: Int! $nextPageCursor: String + $vueLfsEnabled: Boolean = false ) { project(fullPath: $projectPath) { repository { @@ -46,7 +47,7 @@ query getFiles( node { ...TreeEntry webUrl - lfsOid + lfsOid @include(if: $vueLfsEnabled) } } pageInfo { diff --git a/app/assets/javascripts/repository/queries/getVueFileListLfsBadge.query.graphql b/app/assets/javascripts/repository/queries/getVueFileListLfsBadge.query.graphql new file mode 100644 index 0000000000000000000000000000000000000000..3c3d14881da6976ba26a3b64109c1f857acebc97 --- /dev/null +++ b/app/assets/javascripts/repository/queries/getVueFileListLfsBadge.query.graphql @@ -0,0 +1,3 @@ +query getProjectShortPath { + vueFileListLfsBadge @client +} diff --git a/app/assets/javascripts/repository/queries/pathLastCommit.query.graphql b/app/assets/javascripts/repository/queries/pathLastCommit.query.graphql index 9be025afe39bff6176c62f56d800192caca2d067..c812614e94ded832d75eeed61f8888fd6cde0ea4 100644 --- a/app/assets/javascripts/repository/queries/pathLastCommit.query.graphql +++ b/app/assets/javascripts/repository/queries/pathLastCommit.query.graphql @@ -6,6 +6,7 @@ query pathLastCommit($projectPath: ID!, $path: String, $ref: String!) { sha title description + message webUrl authoredDate authorName diff --git a/app/assets/javascripts/repository/utils/readme.js b/app/assets/javascripts/repository/utils/readme.js index e43b2bdc33a30b4516978f785e1e43307add1210..5b62271b02e0c5dff54c04d12704b855a152456b 100644 --- a/app/assets/javascripts/repository/utils/readme.js +++ b/app/assets/javascripts/repository/utils/readme.js @@ -1,21 +1,32 @@ -const MARKDOWN_EXTENSIONS = ['mdown', 'mkd', 'mkdn', 'md', 'markdown']; -const ASCIIDOC_EXTENSIONS = ['adoc', 'ad', 'asciidoc']; -const OTHER_EXTENSIONS = ['textile', 'rdoc', 'org', 'creole', 'wiki', 'mediawiki', 'rst']; -const EXTENSIONS = [...MARKDOWN_EXTENSIONS, ...ASCIIDOC_EXTENSIONS, ...OTHER_EXTENSIONS]; -const PLAIN_FILENAMES = ['readme', 'index']; -const FILE_REGEXP = new RegExp( - `^(${PLAIN_FILENAMES.join('|')})(.(${EXTENSIONS.join('|')}))?$`, - 'i', -); -const PLAIN_FILE_REGEXP = new RegExp(`^(${PLAIN_FILENAMES.join('|')})`, 'i'); -const EXTENSIONS_REGEXP = new RegExp(`.(${EXTENSIONS.join('|')})$`, 'i'); +const FILENAMES = ['index', 'readme']; -// eslint-disable-next-line import/prefer-default-export -export const readmeFile = blobs => { - const readMeFiles = blobs.filter(f => f.name.search(FILE_REGEXP) !== -1); +const MARKUP_EXTENSIONS = [ + 'ad', + 'adoc', + 'asciidoc', + 'creole', + 'markdown', + 'md', + 'mdown', + 'mediawiki', + 'mkd', + 'mkdn', + 'org', + 'rdoc', + 'rst', + 'textile', + 'wiki', +]; - const previewableReadme = readMeFiles.find(f => f.name.search(EXTENSIONS_REGEXP) !== -1); - const plainReadme = readMeFiles.find(f => f.name.search(PLAIN_FILE_REGEXP) !== -1); +const isRichReadme = file => { + const re = new RegExp(`^(${FILENAMES.join('|')})\\.(${MARKUP_EXTENSIONS.join('|')})$`, 'i'); + return re.test(file.name); +}; - return previewableReadme || plainReadme; +const isPlainReadme = file => { + const re = new RegExp(`^(${FILENAMES.join('|')})(\\.txt)?$`, 'i'); + return re.test(file.name); }; + +// eslint-disable-next-line import/prefer-default-export +export const readmeFile = blobs => blobs.find(isRichReadme) || blobs.find(isPlainReadme); diff --git a/app/assets/javascripts/self_monitor/components/self_monitor_form.vue b/app/assets/javascripts/self_monitor/components/self_monitor_form.vue new file mode 100644 index 0000000000000000000000000000000000000000..2f364eae67fa8cbe940dd70d52aca97773a33d93 --- /dev/null +++ b/app/assets/javascripts/self_monitor/components/self_monitor_form.vue @@ -0,0 +1,160 @@ +<script> +import Vue from 'vue'; +import { GlFormGroup, GlButton, GlModal, GlToast, GlToggle } from '@gitlab/ui'; +import { mapState, mapActions } from 'vuex'; +import { __, s__, sprintf } from '~/locale'; +import { visitUrl, getBaseURL } from '~/lib/utils/url_utility'; + +Vue.use(GlToast); + +export default { + components: { + GlFormGroup, + GlButton, + GlModal, + GlToggle, + }, + formLabels: { + createProject: __('Create Project'), + }, + data() { + return { + modalId: 'delete-self-monitor-modal', + }; + }, + computed: { + ...mapState('selfMonitoring', [ + 'projectEnabled', + 'projectCreated', + 'showAlert', + 'projectPath', + 'loading', + 'alertContent', + ]), + selfMonitorEnabled: { + get() { + return this.projectEnabled; + }, + set(projectEnabled) { + this.setSelfMonitor(projectEnabled); + }, + }, + selfMonitorProjectFullUrl() { + return `${getBaseURL()}/${this.projectPath}`; + }, + selfMonitoringFormText() { + if (this.projectCreated) { + return sprintf( + s__( + 'SelfMonitoring|Enabling this feature creates a %{projectLinkStart}project%{projectLinkEnd} that can be used to monitor the health of your instance.', + ), + { + projectLinkStart: `<a href="${this.selfMonitorProjectFullUrl}">`, + projectLinkEnd: '</a>', + }, + false, + ); + } + + return s__( + 'SelfMonitoring|Enabling this feature creates a project that can be used to monitor the health of your instance.', + ); + }, + }, + watch: { + selfMonitorEnabled() { + this.saveChangesSelfMonitorProject(); + }, + showAlert() { + let toastOptions = { + onComplete: () => { + this.resetAlert(); + }, + }; + + if (this.showAlert) { + if (this.alertContent.actionName && this.alertContent.actionName.length > 0) { + toastOptions = { + ...toastOptions, + action: { + text: this.alertContent.actionText, + onClick: (_, toastObject) => { + this[this.alertContent.actionName](); + toastObject.goAway(0); + }, + }, + }; + } + this.$toast.show(this.alertContent.message, toastOptions); + } + }, + }, + methods: { + ...mapActions('selfMonitoring', [ + 'setSelfMonitor', + 'createProject', + 'deleteProject', + 'resetAlert', + ]), + hideSelfMonitorModal() { + this.$root.$emit('bv::hide::modal', this.modalId); + this.setSelfMonitor(true); + }, + showSelfMonitorModal() { + this.$root.$emit('bv::show::modal', this.modalId); + }, + saveChangesSelfMonitorProject() { + if (this.projectCreated && !this.projectEnabled) { + this.showSelfMonitorModal(); + } else { + this.createProject(); + } + }, + viewSelfMonitorProject() { + visitUrl(this.selfMonitorProjectFullUrl); + }, + }, +}; +</script> +<template> + <section class="settings no-animate js-self-monitoring-settings"> + <div class="settings-header"> + <h4 class="js-section-header"> + {{ s__('SelfMonitoring|Self monitoring') }} + </h4> + <gl-button class="js-settings-toggle">{{ __('Expand') }}</gl-button> + <p class="js-section-sub-header"> + {{ s__('SelfMonitoring|Enable or disable instance self monitoring') }} + </p> + </div> + <div class="settings-content"> + <form name="self-monitoring-form"> + <p v-html="selfMonitoringFormText"></p> + <gl-form-group :label="$options.formLabels.createProject" label-for="self-monitor-toggle"> + <gl-toggle + v-model="selfMonitorEnabled" + :is-loading="loading" + name="self-monitor-toggle" + /> + </gl-form-group> + </form> + </div> + <gl-modal + :title="s__('SelfMonitoring|Disable self monitoring?')" + :modal-id="modalId" + :ok-title="__('Delete project')" + :cancel-title="__('Cancel')" + ok-variant="danger" + @ok="deleteProject" + @cancel="hideSelfMonitorModal" + > + <div> + {{ + s__( + 'SelfMonitoring|Disabling this feature will delete the self monitoring project. Are you sure you want to delete the project?', + ) + }} + </div> + </gl-modal> + </section> +</template> diff --git a/app/assets/javascripts/self_monitor/index.js b/app/assets/javascripts/self_monitor/index.js new file mode 100644 index 0000000000000000000000000000000000000000..42c94e119895d0c7146784f17837eb63a99472ae --- /dev/null +++ b/app/assets/javascripts/self_monitor/index.js @@ -0,0 +1,23 @@ +import Vue from 'vue'; +import store from './store'; +import SelfMonitorForm from './components/self_monitor_form.vue'; + +export default () => { + const el = document.querySelector('.js-self-monitoring-settings'); + let selfMonitorProjectCreated; + + if (el) { + selfMonitorProjectCreated = el.dataset.selfMonitoringProjectExists; + // eslint-disable-next-line no-new + new Vue({ + el, + store: store({ + projectEnabled: selfMonitorProjectCreated, + ...el.dataset, + }), + render(createElement) { + return createElement(SelfMonitorForm); + }, + }); + } +}; diff --git a/app/assets/javascripts/self_monitor/store/actions.js b/app/assets/javascripts/self_monitor/store/actions.js new file mode 100644 index 0000000000000000000000000000000000000000..f8430a9b136e6deedac57a115a13a9b32b7f0045 --- /dev/null +++ b/app/assets/javascripts/self_monitor/store/actions.js @@ -0,0 +1,126 @@ +import { __, s__ } from '~/locale'; +import axios from '~/lib/utils/axios_utils'; +import statusCodes from '~/lib/utils/http_status'; +import { backOff } from '~/lib/utils/common_utils'; +import * as types from './mutation_types'; + +const TWO_MINUTES = 120000; + +function backOffRequest(makeRequestCallback) { + return backOff((next, stop) => { + makeRequestCallback() + .then(resp => { + if (resp.status === statusCodes.ACCEPTED) { + next(); + } else { + stop(resp); + } + }) + .catch(stop); + }, TWO_MINUTES); +} + +export const setSelfMonitor = ({ commit }, enabled) => commit(types.SET_ENABLED, enabled); + +export const createProject = ({ dispatch }) => dispatch('requestCreateProject'); + +export const resetAlert = ({ commit }) => commit(types.SET_SHOW_ALERT, false); + +export const requestCreateProject = ({ dispatch, state, commit }) => { + commit(types.SET_LOADING, true); + axios + .post(state.createProjectEndpoint) + .then(resp => { + if (resp.status === statusCodes.ACCEPTED) { + dispatch('requestCreateProjectStatus', resp.data.job_id); + } + }) + .catch(error => { + dispatch('requestCreateProjectError', error); + }); +}; + +export const requestCreateProjectStatus = ({ dispatch, state }, jobId) => { + backOffRequest(() => axios.get(state.createProjectStatusEndpoint, { params: { job_id: jobId } })) + .then(resp => { + if (resp.status === statusCodes.OK) { + dispatch('requestCreateProjectSuccess', resp.data); + } + }) + .catch(error => { + dispatch('requestCreateProjectError', error); + }); +}; + +export const requestCreateProjectSuccess = ({ commit }, selfMonitorData) => { + commit(types.SET_LOADING, false); + commit(types.SET_PROJECT_URL, selfMonitorData.project_full_path); + commit(types.SET_ALERT_CONTENT, { + message: s__('SelfMonitoring|Self monitoring project has been successfully created.'), + actionText: __('View project'), + actionName: 'viewSelfMonitorProject', + }); + commit(types.SET_SHOW_ALERT, true); + commit(types.SET_PROJECT_CREATED, true); +}; + +export const requestCreateProjectError = ({ commit }, error) => { + const { response } = error; + const message = response.data && response.data.message ? response.data.message : ''; + + commit(types.SET_ALERT_CONTENT, { + message: `${__('There was an error saving your changes.')} ${message}`, + }); + commit(types.SET_SHOW_ALERT, true); + commit(types.SET_LOADING, false); +}; + +export const deleteProject = ({ dispatch }) => dispatch('requestDeleteProject'); + +export const requestDeleteProject = ({ dispatch, state, commit }) => { + commit(types.SET_LOADING, true); + axios + .delete(state.deleteProjectEndpoint) + .then(resp => { + if (resp.status === statusCodes.ACCEPTED) { + dispatch('requestDeleteProjectStatus', resp.data.job_id); + } + }) + .catch(error => { + dispatch('requestDeleteProjectError', error); + }); +}; + +export const requestDeleteProjectStatus = ({ dispatch, state }, jobId) => { + backOffRequest(() => axios.get(state.deleteProjectStatusEndpoint, { params: { job_id: jobId } })) + .then(resp => { + if (resp.status === statusCodes.OK) { + dispatch('requestDeleteProjectSuccess', resp.data); + } + }) + .catch(error => { + dispatch('requestDeleteProjectError', error); + }); +}; + +export const requestDeleteProjectSuccess = ({ commit }) => { + commit(types.SET_PROJECT_URL, ''); + commit(types.SET_PROJECT_CREATED, false); + commit(types.SET_ALERT_CONTENT, { + message: s__('SelfMonitoring|Self monitoring project has been successfully deleted.'), + actionText: __('Undo'), + actionName: 'createProject', + }); + commit(types.SET_SHOW_ALERT, true); + commit(types.SET_LOADING, false); +}; + +export const requestDeleteProjectError = ({ commit }, error) => { + const { response } = error; + const message = response.data && response.data.message ? response.data.message : ''; + + commit(types.SET_ALERT_CONTENT, { + message: `${__('There was an error saving your changes.')} ${message}`, + }); + commit(types.SET_LOADING, false); +}; diff --git a/app/assets/javascripts/self_monitor/store/index.js b/app/assets/javascripts/self_monitor/store/index.js new file mode 100644 index 0000000000000000000000000000000000000000..a222e9c87b87a4d4a9a5e6f49ace9298269c28c0 --- /dev/null +++ b/app/assets/javascripts/self_monitor/store/index.js @@ -0,0 +1,21 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import createState from './state'; +import * as actions from './actions'; +import mutations from './mutations'; + +Vue.use(Vuex); + +export const createStore = initialState => + new Vuex.Store({ + modules: { + selfMonitoring: { + namespaced: true, + state: createState(initialState), + actions, + mutations, + }, + }, + }); + +export default createStore; diff --git a/app/assets/javascripts/self_monitor/store/mutation_types.js b/app/assets/javascripts/self_monitor/store/mutation_types.js new file mode 100644 index 0000000000000000000000000000000000000000..c5952b66144f1773bd9e651916b194b916d07092 --- /dev/null +++ b/app/assets/javascripts/self_monitor/store/mutation_types.js @@ -0,0 +1,6 @@ +export const SET_ENABLED = 'SET_ENABLED'; +export const SET_PROJECT_CREATED = 'SET_PROJECT_CREATED'; +export const SET_SHOW_ALERT = 'SET_SHOW_ALERT'; +export const SET_PROJECT_URL = 'SET_PROJECT_URL'; +export const SET_LOADING = 'SET_LOADING'; +export const SET_ALERT_CONTENT = 'SET_ALERT_CONTENT'; diff --git a/app/assets/javascripts/self_monitor/store/mutations.js b/app/assets/javascripts/self_monitor/store/mutations.js new file mode 100644 index 0000000000000000000000000000000000000000..7dca8bcdc4dce86a0a29d9930d906f50bac93340 --- /dev/null +++ b/app/assets/javascripts/self_monitor/store/mutations.js @@ -0,0 +1,22 @@ +import * as types from './mutation_types'; + +export default { + [types.SET_ENABLED](state, enabled) { + state.projectEnabled = enabled; + }, + [types.SET_PROJECT_CREATED](state, created) { + state.projectCreated = created; + }, + [types.SET_SHOW_ALERT](state, show) { + state.showAlert = show; + }, + [types.SET_PROJECT_URL](state, url) { + state.projectPath = url; + }, + [types.SET_LOADING](state, loading) { + state.loading = loading; + }, + [types.SET_ALERT_CONTENT](state, content) { + state.alertContent = content; + }, +}; diff --git a/app/assets/javascripts/self_monitor/store/state.js b/app/assets/javascripts/self_monitor/store/state.js new file mode 100644 index 0000000000000000000000000000000000000000..b8b4a4af61433bbb2c04cfa9f33e6d0263ca5eb2 --- /dev/null +++ b/app/assets/javascripts/self_monitor/store/state.js @@ -0,0 +1,15 @@ +import { parseBoolean } from '~/lib/utils/common_utils'; + +export default (initialState = {}) => ({ + projectEnabled: parseBoolean(initialState.projectEnabled) || false, + projectCreated: parseBoolean(initialState.selfMonitorProjectCreated) || false, + createProjectEndpoint: initialState.createSelfMonitoringProjectPath || '', + deleteProjectEndpoint: initialState.deleteSelfMonitoringProjectPath || '', + createProjectStatusEndpoint: initialState.statusCreateSelfMonitoringProjectPath || '', + deleteProjectStatusEndpoint: initialState.statusDeleteSelfMonitoringProjectPath || '', + selfMonitorProjectPath: initialState.selfMonitoringProjectFullPath || '', + showAlert: false, + projectPath: '', + loading: false, + alertContent: {}, +}); diff --git a/app/assets/javascripts/sentry_error_stack_trace/components/sentry_error_stack_trace.vue b/app/assets/javascripts/sentry_error_stack_trace/components/sentry_error_stack_trace.vue new file mode 100644 index 0000000000000000000000000000000000000000..c90478db6206443688d25662a22eabd40df5d45f --- /dev/null +++ b/app/assets/javascripts/sentry_error_stack_trace/components/sentry_error_stack_trace.vue @@ -0,0 +1,43 @@ +<script> +import Stacktrace from '~/error_tracking/components/stacktrace.vue'; +import { GlLoadingIcon } from '@gitlab/ui'; +import { mapActions, mapState, mapGetters } from 'vuex'; + +export default { + name: 'SentryErrorStackTrace', + components: { + Stacktrace, + GlLoadingIcon, + }, + props: { + issueStackTracePath: { + type: String, + required: true, + }, + }, + computed: { + ...mapState('details', ['loadingStacktrace', 'stacktraceData']), + ...mapGetters('details', ['stacktrace']), + }, + mounted() { + this.startPollingStacktrace(this.issueStackTracePath); + }, + methods: { + ...mapActions('details', ['startPollingStacktrace']), + }, +}; +</script> + +<template> + <div> + <div :class="{ 'border-bottom-0': loadingStacktrace }" class="card card-slim mt-4 mb-0"> + <div class="card-header border-bottom-0"> + <h5 class="card-title my-1">{{ __('Stack trace') }}</h5> + </div> + </div> + <div v-if="loadingStacktrace" class="card"> + <gl-loading-icon class="py-2" label="Fetching stack trace" :size="1" /> + </div> + <stacktrace v-else :entries="stacktrace" /> + </div> +</template> diff --git a/app/assets/javascripts/sentry_error_stack_trace/index.js b/app/assets/javascripts/sentry_error_stack_trace/index.js new file mode 100644 index 0000000000000000000000000000000000000000..9b24ddc335d8c9d10ffa64667d1f002cf16b1dd3 --- /dev/null +++ b/app/assets/javascripts/sentry_error_stack_trace/index.js @@ -0,0 +1,22 @@ +import Vue from 'vue'; +import SentryErrorStackTrace from './components/sentry_error_stack_trace.vue'; +import store from '~/error_tracking/store'; + +export default function initSentryErrorStacktrace() { + const sentryErrorStackTraceEl = document.querySelector('#js-sentry-error-stack-trace'); + if (sentryErrorStackTraceEl) { + const { issueStackTracePath } = sentryErrorStackTraceEl.dataset; + // eslint-disable-next-line no-new + new Vue({ + el: sentryErrorStackTraceEl, + components: { + SentryErrorStackTrace, + }, + store, + render: createElement => + createElement('sentry-error-stack-trace', { + props: { issueStackTracePath }, + }), + }); + } +} diff --git a/app/assets/javascripts/serverless/components/functions.vue b/app/assets/javascripts/serverless/components/functions.vue index 308bc4a2ddd530372a0f93ed60d7596975246837..cdbf57f3e55a4ab80329e471370e06ad448333d4 100644 --- a/app/assets/javascripts/serverless/components/functions.vue +++ b/app/assets/javascripts/serverless/components/functions.vue @@ -2,7 +2,6 @@ import { mapState, mapActions, mapGetters } from 'vuex'; import { GlLoadingIcon } from '@gitlab/ui'; import { sprintf, s__ } from '~/locale'; -import FunctionRow from './function_row.vue'; import EnvironmentRow from './environment_row.vue'; import EmptyState from './empty_state.vue'; import { CHECKING_INSTALLED } from '../constants'; @@ -10,7 +9,6 @@ import { CHECKING_INSTALLED } from '../constants'; export default { components: { EnvironmentRow, - FunctionRow, EmptyState, GlLoadingIcon, }, diff --git a/app/assets/javascripts/sidebar/components/participants/participants.vue b/app/assets/javascripts/sidebar/components/participants/participants.vue index 38b19d66163617bfc4590520e16097da3553a9a7..f2ef7a2268e61f0bf411e072d70ce4ce42e18359 100644 --- a/app/assets/javascripts/sidebar/components/participants/participants.vue +++ b/app/assets/javascripts/sidebar/components/participants/participants.vue @@ -28,6 +28,11 @@ export default { required: false, default: 7, }, + showParticipantLabel: { + type: Boolean, + required: false, + default: true, + }, }, data() { return { @@ -80,6 +85,7 @@ export default { <template> <div> <div + v-if="showParticipantLabel" v-tooltip :title="participantLabel" class="sidebar-collapsed-icon" @@ -92,7 +98,7 @@ export default { <gl-loading-icon v-if="loading" class="js-participants-collapsed-loading-icon" /> <span v-else class="js-participants-collapsed-count"> {{ participantCount }} </span> </div> - <div class="title hide-collapsed"> + <div v-if="showParticipantLabel" class="title hide-collapsed"> <gl-loading-icon v-if="loading" :inline="true" diff --git a/app/assets/javascripts/snippets/components/app.vue b/app/assets/javascripts/snippets/components/app.vue index bd2cb8e45954cb398540d6fb21fca0903b76a047..7a2145a800ccff96b98291aa8996f669027bb984 100644 --- a/app/assets/javascripts/snippets/components/app.vue +++ b/app/assets/javascripts/snippets/components/app.vue @@ -1,11 +1,13 @@ <script> import GetSnippetQuery from '../queries/snippet.query.graphql'; import SnippetHeader from './snippet_header.vue'; +import SnippetTitle from './snippet_title.vue'; import { GlLoadingIcon } from '@gitlab/ui'; export default { components: { SnippetHeader, + SnippetTitle, GlLoadingIcon, }, apollo: { @@ -45,6 +47,9 @@ export default { :size="2" class="loading-animation prepend-top-20 append-bottom-20" /> - <snippet-header v-else :snippet="snippet" /> + <template v-else> + <snippet-header :snippet="snippet" /> + <snippet-title :snippet="snippet" /> + </template> </div> </template> diff --git a/app/assets/javascripts/snippets/components/snippet_title.vue b/app/assets/javascripts/snippets/components/snippet_title.vue new file mode 100644 index 0000000000000000000000000000000000000000..fc8a9b4a39046fe54d35ddacd7cb3bc0e65a7c63 --- /dev/null +++ b/app/assets/javascripts/snippets/components/snippet_title.vue @@ -0,0 +1,35 @@ +<script> +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import { GlSprintf } from '@gitlab/ui'; + +export default { + components: { + TimeAgoTooltip, + GlSprintf, + }, + props: { + snippet: { + type: Object, + required: true, + }, + }, +}; +</script> +<template> + <div class="snippet-header limited-header-width"> + <h2 class="snippet-title prepend-top-0 mb-3" data-qa-selector="snippet_title"> + {{ snippet.title }} + </h2> + <div v-if="snippet.description" class="description" data-qa-selector="snippet_description"> + <div class="md">{{ snippet.description }}</div> + </div> + + <small v-if="snippet.updatedAt !== snippet.createdAt" class="edited-text"> + <gl-sprintf message="Edited %{timeago}"> + <template #timeago> + <time-ago-tooltip :time="snippet.updatedAt" tooltip-placement="bottom" /> + </template> + </gl-sprintf> + </small> + </div> +</template> diff --git a/app/assets/javascripts/tree.js b/app/assets/javascripts/tree.js index a530c4a99e272f46d2258606e4608bdb3762aa86..59276ee79d80c9d5c3f421b06896dff2203fd698 100644 --- a/app/assets/javascripts/tree.js +++ b/app/assets/javascripts/tree.js @@ -21,7 +21,9 @@ export default class TreeView { } }); // Show the "Loading commit data" for only the first element - $('span.log_loading:first').removeClass('hide'); + $('span.log_loading') + .first() + .removeClass('hide'); } initKeyNav() { diff --git a/app/assets/javascripts/users_select.js b/app/assets/javascripts/users_select.js index 57fbb88ca2e9026929978491baf2f49296ad420b..6d7d863f273d86a81bfcb44e04ca7da9792b5260 100644 --- a/app/assets/javascripts/users_select.js +++ b/app/assets/javascripts/users_select.js @@ -27,633 +27,623 @@ function UsersSelect(currentUser, els, options = {}) { } const { handleClick } = options; + const userSelect = this; + + $els.each((i, dropdown) => { + const userSelect = this; + const options = {}; + const $dropdown = $(dropdown); + options.projectId = $dropdown.data('projectId'); + options.groupId = $dropdown.data('groupId'); + options.showCurrentUser = $dropdown.data('currentUser'); + options.todoFilter = $dropdown.data('todoFilter'); + options.todoStateFilter = $dropdown.data('todoStateFilter'); + options.iid = $dropdown.data('iid'); + options.issuableType = $dropdown.data('issuableType'); + const showNullUser = $dropdown.data('nullUser'); + const defaultNullUser = $dropdown.data('nullUserDefault'); + const showMenuAbove = $dropdown.data('showMenuAbove'); + const showAnyUser = $dropdown.data('anyUser'); + const firstUser = $dropdown.data('firstUser'); + options.authorId = $dropdown.data('authorId'); + const defaultLabel = $dropdown.data('defaultLabel'); + const issueURL = $dropdown.data('issueUpdate'); + const $selectbox = $dropdown.closest('.selectbox'); + let $block = $selectbox.closest('.block'); + const abilityName = $dropdown.data('abilityName'); + let $value = $block.find('.value'); + const $collapsedSidebar = $block.find('.sidebar-collapsed-user'); + // eslint-disable-next-line no-jquery/no-fade + const $loading = $block.find('.block-loading').fadeOut(); + const selectedIdDefault = defaultNullUser && showNullUser ? 0 : null; + let selectedId = $dropdown.data('selected'); + let assignTo; + let assigneeTemplate; + let collapsedAssigneeTemplate; + + if (selectedId === undefined) { + selectedId = selectedIdDefault; + } - $els.each( - (function(_this) { - return function(i, dropdown) { - const options = {}; - const $dropdown = $(dropdown); - options.projectId = $dropdown.data('projectId'); - options.groupId = $dropdown.data('groupId'); - options.showCurrentUser = $dropdown.data('currentUser'); - options.todoFilter = $dropdown.data('todoFilter'); - options.todoStateFilter = $dropdown.data('todoStateFilter'); - options.iid = $dropdown.data('iid'); - options.issuableType = $dropdown.data('issuableType'); - const showNullUser = $dropdown.data('nullUser'); - const defaultNullUser = $dropdown.data('nullUserDefault'); - const showMenuAbove = $dropdown.data('showMenuAbove'); - const showAnyUser = $dropdown.data('anyUser'); - const firstUser = $dropdown.data('firstUser'); - options.authorId = $dropdown.data('authorId'); - const defaultLabel = $dropdown.data('defaultLabel'); - const issueURL = $dropdown.data('issueUpdate'); - const $selectbox = $dropdown.closest('.selectbox'); - let $block = $selectbox.closest('.block'); - const abilityName = $dropdown.data('abilityName'); - let $value = $block.find('.value'); - const $collapsedSidebar = $block.find('.sidebar-collapsed-user'); - const $loading = $block.find('.block-loading').fadeOut(); - const selectedIdDefault = defaultNullUser && showNullUser ? 0 : null; - let selectedId = $dropdown.data('selected'); - let assignTo; - let assigneeTemplate; - let collapsedAssigneeTemplate; - - if (selectedId === undefined) { - selectedId = selectedIdDefault; - } - - const assignYourself = function() { - const unassignedSelected = $dropdown - .closest('.selectbox') - .find(`input[name='${$dropdown.data('fieldName')}'][value=0]`); + const assignYourself = function() { + const unassignedSelected = $dropdown + .closest('.selectbox') + .find(`input[name='${$dropdown.data('fieldName')}'][value=0]`); + + if (unassignedSelected) { + unassignedSelected.remove(); + } + + // Save current selected user to the DOM + const currentUserInfo = $dropdown.data('currentUserInfo') || {}; + const currentUser = userSelect.currentUser || {}; + const fieldName = $dropdown.data('fieldName'); + const userName = currentUserInfo.name; + const userId = currentUserInfo.id || currentUser.id; + + const inputHtmlString = _.template(` + <input type="hidden" name="<%- fieldName %>" + data-meta="<%- userName %>" + value="<%- userId %>" /> + `)({ fieldName, userName, userId }); + + if ($selectbox) { + $dropdown.parent().before(inputHtmlString); + } else { + $dropdown.after(inputHtmlString); + } + }; - if (unassignedSelected) { - unassignedSelected.remove(); - } + if ($block[0]) { + $block[0].addEventListener('assignYourself', assignYourself); + } - // Save current selected user to the DOM - const currentUserInfo = $dropdown.data('currentUserInfo') || {}; - const currentUser = _this.currentUser || {}; - const fieldName = $dropdown.data('fieldName'); - const userName = currentUserInfo.name; - const userId = currentUserInfo.id || currentUser.id; - - const inputHtmlString = _.template(` - <input type="hidden" name="<%- fieldName %>" - data-meta="<%- userName %>" - value="<%- userId %>" /> - `)({ fieldName, userName, userId }); - - if ($selectbox) { - $dropdown.parent().before(inputHtmlString); - } else { - $dropdown.after(inputHtmlString); - } - }; + const getSelectedUserInputs = function() { + return $selectbox.find(`input[name="${$dropdown.data('fieldName')}"]`); + }; - if ($block[0]) { - $block[0].addEventListener('assignYourself', assignYourself); - } + const getSelected = function() { + return getSelectedUserInputs() + .map((index, input) => parseInt(input.value, 10)) + .get(); + }; - const getSelectedUserInputs = function() { - return $selectbox.find(`input[name="${$dropdown.data('fieldName')}"]`); - }; - - const getSelected = function() { - return getSelectedUserInputs() - .map((index, input) => parseInt(input.value, 10)) - .get(); - }; - - const checkMaxSelect = function() { - const maxSelect = $dropdown.data('maxSelect'); - if (maxSelect) { - const selected = getSelected(); - - if (selected.length > maxSelect) { - const firstSelectedId = selected[0]; - const firstSelected = $dropdown - .closest('.selectbox') - .find(`input[name='${$dropdown.data('fieldName')}'][value=${firstSelectedId}]`); - - firstSelected.remove(); - emitSidebarEvent('sidebar.removeAssignee', { - id: firstSelectedId, - }); - } - } - }; - - const getMultiSelectDropdownTitle = function(selectedUser, isSelected) { - const selectedUsers = getSelected().filter(u => u !== 0); - - const firstUser = getSelectedUserInputs() - .map((index, input) => ({ - name: input.dataset.meta, - value: parseInt(input.value, 10), - })) - .filter(u => u.id !== 0) - .get(0); - - if (selectedUsers.length === 0) { - return s__('UsersSelect|Unassigned'); - } else if (selectedUsers.length === 1) { - return firstUser.name; - } else if (isSelected) { - const otherSelected = selectedUsers.filter(s => s !== selectedUser.id); - return sprintf(s__('UsersSelect|%{name} + %{length} more'), { - name: selectedUser.name, - length: otherSelected.length, - }); - } else { - return sprintf(s__('UsersSelect|%{name} + %{length} more'), { - name: firstUser.name, - length: selectedUsers.length - 1, - }); - } - }; + const checkMaxSelect = function() { + const maxSelect = $dropdown.data('maxSelect'); + if (maxSelect) { + const selected = getSelected(); - $('.assign-to-me-link').on('click', e => { - e.preventDefault(); - $(e.currentTarget).hide(); + if (selected.length > maxSelect) { + const firstSelectedId = selected[0]; + const firstSelected = $dropdown + .closest('.selectbox') + .find(`input[name='${$dropdown.data('fieldName')}'][value=${firstSelectedId}]`); - if ($dropdown.data('multiSelect')) { - assignYourself(); - checkMaxSelect(); + firstSelected.remove(); + emitSidebarEvent('sidebar.removeAssignee', { + id: firstSelectedId, + }); + } + } + }; - const currentUserInfo = $dropdown.data('currentUserInfo'); - $dropdown - .find('.dropdown-toggle-text') - .text(getMultiSelectDropdownTitle(currentUserInfo)) - .removeClass('is-default'); - } else { - const $input = $(`input[name="${$dropdown.data('fieldName')}"]`); - $input.val(gon.current_user_id); - selectedId = $input.val(); - $dropdown - .find('.dropdown-toggle-text') - .text(gon.current_user_fullname) - .removeClass('is-default'); - } + const getMultiSelectDropdownTitle = function(selectedUser, isSelected) { + const selectedUsers = getSelected().filter(u => u !== 0); + + const firstUser = getSelectedUserInputs() + .map((index, input) => ({ + name: input.dataset.meta, + value: parseInt(input.value, 10), + })) + .filter(u => u.id !== 0) + .get(0); + + if (selectedUsers.length === 0) { + return s__('UsersSelect|Unassigned'); + } else if (selectedUsers.length === 1) { + return firstUser.name; + } else if (isSelected) { + const otherSelected = selectedUsers.filter(s => s !== selectedUser.id); + return sprintf(s__('UsersSelect|%{name} + %{length} more'), { + name: selectedUser.name, + length: otherSelected.length, }); - - $block.on('click', '.js-assign-yourself', e => { - e.preventDefault(); - return assignTo(_this.currentUser.id); + } else { + return sprintf(s__('UsersSelect|%{name} + %{length} more'), { + name: firstUser.name, + length: selectedUsers.length - 1, }); + } + }; - assignTo = function(selected) { - const data = {}; - data[abilityName] = {}; - data[abilityName].assignee_id = selected != null ? selected : null; - $loading.removeClass('hidden').fadeIn(); - $dropdown.trigger('loading.gl.dropdown'); - - return axios.put(issueURL, data).then(({ data }) => { - let user = {}; - let tooltipTitle = user.name; - $dropdown.trigger('loaded.gl.dropdown'); - $loading.fadeOut(); - if (data.assignee) { - user = { - name: data.assignee.name, - username: data.assignee.username, - avatar: data.assignee.avatar_url, - }; - tooltipTitle = _.escape(user.name); - } else { - user = { - name: s__('UsersSelect|Unassigned'), - username: '', - avatar: '', + $('.assign-to-me-link').on('click', e => { + e.preventDefault(); + $(e.currentTarget).hide(); + + if ($dropdown.data('multiSelect')) { + assignYourself(); + checkMaxSelect(); + + const currentUserInfo = $dropdown.data('currentUserInfo'); + $dropdown + .find('.dropdown-toggle-text') + .text(getMultiSelectDropdownTitle(currentUserInfo)) + .removeClass('is-default'); + } else { + const $input = $(`input[name="${$dropdown.data('fieldName')}"]`); + $input.val(gon.current_user_id); + selectedId = $input.val(); + $dropdown + .find('.dropdown-toggle-text') + .text(gon.current_user_fullname) + .removeClass('is-default'); + } + }); + + $block.on('click', '.js-assign-yourself', e => { + e.preventDefault(); + return assignTo(userSelect.currentUser.id); + }); + + assignTo = function(selected) { + const data = {}; + data[abilityName] = {}; + data[abilityName].assignee_id = selected != null ? selected : null; + // eslint-disable-next-line no-jquery/no-fade + $loading.removeClass('hidden').fadeIn(); + $dropdown.trigger('loading.gl.dropdown'); + + return axios.put(issueURL, data).then(({ data }) => { + let user = {}; + let tooltipTitle = user.name; + $dropdown.trigger('loaded.gl.dropdown'); + // eslint-disable-next-line no-jquery/no-fade + $loading.fadeOut(); + if (data.assignee) { + user = { + name: data.assignee.name, + username: data.assignee.username, + avatar: data.assignee.avatar_url, + }; + tooltipTitle = _.escape(user.name); + } else { + user = { + name: s__('UsersSelect|Unassigned'), + username: '', + avatar: '', + }; + tooltipTitle = s__('UsersSelect|Assignee'); + } + $value.html(assigneeTemplate(user)); + $collapsedSidebar.attr('title', tooltipTitle).tooltip('_fixTitle'); + return $collapsedSidebar.html(collapsedAssigneeTemplate(user)); + }); + }; + collapsedAssigneeTemplate = _.template( + '<% if( avatar ) { %> <a class="author-link" href="/<%- username %>"> <img width="24" class="avatar avatar-inline s24" alt="" src="<%- avatar %>"> </a> <% } else { %> <i class="fa fa-user"></i> <% } %>', + ); + assigneeTemplate = _.template( + `<% if (username) { %> <a class="author-link bold" href="/<%- username %>"> <% if( avatar ) { %> <img width="32" class="avatar avatar-inline s32" alt="" src="<%- avatar %>"> <% } %> <span class="author"><%- name %></span> <span class="username"> @<%- username %> </span> </a> <% } else { %> <span class="no-value assign-yourself"> + ${sprintf(s__('UsersSelect|No assignee - %{openingTag} assign yourself %{closingTag}'), { + openingTag: '<a href="#" class="js-assign-yourself">', + closingTag: '</a>', + })}</span> <% } %>`, + ); + return $dropdown.glDropdown({ + showMenuAbove, + data(term, callback) { + return userSelect.users(term, options, users => { + // GitLabDropdownFilter returns this.instance + // GitLabDropdownRemote returns this.options.instance + const glDropdown = this.instance || this.options.instance; + glDropdown.options.processData(term, users, callback); + }); + }, + processData(term, data, callback) { + let users = data; + + // Only show assigned user list when there is no search term + if ($dropdown.hasClass('js-multiselect') && term.length === 0) { + const selectedInputs = getSelectedUserInputs(); + + // Potential duplicate entries when dealing with issue board + // because issue board is also managed by vue + const selectedUsers = _.uniq(selectedInputs, false, a => a.value) + .filter(input => { + const userId = parseInt(input.value, 10); + const inUsersArray = users.find(u => u.id === userId); + + return !inUsersArray && userId !== 0; + }) + .map(input => { + const userId = parseInt(input.value, 10); + const { avatarUrl, avatar_url, name, username, canMerge } = input.dataset; + return { + avatar_url: avatarUrl || avatar_url, + id: userId, + name, + username, + can_merge: parseBoolean(canMerge), }; - tooltipTitle = s__('UsersSelect|Assignee'); - } - $value.html(assigneeTemplate(user)); - $collapsedSidebar.attr('title', tooltipTitle).tooltip('_fixTitle'); - return $collapsedSidebar.html(collapsedAssigneeTemplate(user)); - }); - }; - collapsedAssigneeTemplate = _.template( - '<% if( avatar ) { %> <a class="author-link" href="/<%- username %>"> <img width="24" class="avatar avatar-inline s24" alt="" src="<%- avatar %>"> </a> <% } else { %> <i class="fa fa-user"></i> <% } %>', - ); - assigneeTemplate = _.template( - `<% if (username) { %> <a class="author-link bold" href="/<%- username %>"> <% if( avatar ) { %> <img width="32" class="avatar avatar-inline s32" alt="" src="<%- avatar %>"> <% } %> <span class="author"><%- name %></span> <span class="username"> @<%- username %> </span> </a> <% } else { %> <span class="no-value assign-yourself"> - ${sprintf(s__('UsersSelect|No assignee - %{openingTag} assign yourself %{closingTag}'), { - openingTag: '<a href="#" class="js-assign-yourself">', - closingTag: '</a>', - })}</span> <% } %>`, - ); - return $dropdown.glDropdown({ - showMenuAbove, - data(term, callback) { - return _this.users(term, options, users => { - // GitLabDropdownFilter returns this.instance - // GitLabDropdownRemote returns this.options.instance - const glDropdown = this.instance || this.options.instance; - glDropdown.options.processData(term, users, callback); }); - }, - processData(term, data, callback) { - let users = data; - - // Only show assigned user list when there is no search term - if ($dropdown.hasClass('js-multiselect') && term.length === 0) { - const selectedInputs = getSelectedUserInputs(); - - // Potential duplicate entries when dealing with issue board - // because issue board is also managed by vue - const selectedUsers = _.uniq(selectedInputs, false, a => a.value) - .filter(input => { - const userId = parseInt(input.value, 10); - const inUsersArray = users.find(u => u.id === userId); - - return !inUsersArray && userId !== 0; - }) - .map(input => { - const userId = parseInt(input.value, 10); - const { avatarUrl, avatar_url, name, username, canMerge } = input.dataset; - return { - avatar_url: avatarUrl || avatar_url, - id: userId, - name, - username, - can_merge: parseBoolean(canMerge), - }; - }); - users = data.concat(selectedUsers); - } + users = data.concat(selectedUsers); + } - let anyUser; - let index; - let len; - let name; - let obj; - let showDivider; - if (term.length === 0) { - showDivider = 0; - if (firstUser) { - // Move current user to the front of the list - for (index = 0, len = users.length; index < len; index += 1) { - obj = users[index]; - if (obj.username === firstUser) { - users.splice(index, 1); - users.unshift(obj); - break; - } - } + let anyUser; + let index; + let len; + let name; + let obj; + let showDivider; + if (term.length === 0) { + showDivider = 0; + if (firstUser) { + // Move current user to the front of the list + for (index = 0, len = users.length; index < len; index += 1) { + obj = users[index]; + if (obj.username === firstUser) { + users.splice(index, 1); + users.unshift(obj); + break; } - if (showNullUser) { + } + } + if (showNullUser) { + showDivider += 1; + users.unshift({ + beforeDivider: true, + name: s__('UsersSelect|Unassigned'), + id: 0, + }); + } + if (showAnyUser) { + showDivider += 1; + name = showAnyUser; + if (name === true) { + name = s__('UsersSelect|Any User'); + } + anyUser = { + beforeDivider: true, + name, + id: null, + }; + users.unshift(anyUser); + } + + if (showDivider) { + users.splice(showDivider, 0, { type: 'divider' }); + } + + if ($dropdown.hasClass('js-multiselect')) { + const selected = getSelected().filter(i => i !== 0); + + if (selected.length > 0) { + if ($dropdown.data('dropdownHeader')) { showDivider += 1; - users.unshift({ - beforeDivider: true, - name: s__('UsersSelect|Unassigned'), - id: 0, + users.splice(showDivider, 0, { + type: 'header', + content: $dropdown.data('dropdownHeader'), }); } - if (showAnyUser) { - showDivider += 1; - name = showAnyUser; - if (name === true) { - name = s__('UsersSelect|Any User'); - } - anyUser = { - beforeDivider: true, - name, - id: null, - }; - users.unshift(anyUser); - } - if (showDivider) { - users.splice(showDivider, 0, { type: 'divider' }); - } + const selectedUsers = users + .filter(u => selected.indexOf(u.id) !== -1) + .sort((a, b) => a.name > b.name); - if ($dropdown.hasClass('js-multiselect')) { - const selected = getSelected().filter(i => i !== 0); + users = users.filter(u => selected.indexOf(u.id) === -1); - if (selected.length > 0) { - if ($dropdown.data('dropdownHeader')) { - showDivider += 1; - users.splice(showDivider, 0, { - type: 'header', - content: $dropdown.data('dropdownHeader'), - }); - } + selectedUsers.forEach(selectedUser => { + showDivider += 1; + users.splice(showDivider, 0, selectedUser); + }); - const selectedUsers = users - .filter(u => selected.indexOf(u.id) !== -1) - .sort((a, b) => a.name > b.name); + users.splice(showDivider + 1, 0, { type: 'divider' }); + } + } + } - users = users.filter(u => selected.indexOf(u.id) === -1); + callback(users); + if (showMenuAbove) { + $dropdown.data('glDropdown').positionMenuAbove(); + } + }, + filterable: true, + filterRemote: true, + search: { + fields: ['name', 'username'], + }, + selectable: true, + fieldName: $dropdown.data('fieldName'), + toggleLabel(selected, el, glDropdown) { + const inputValue = glDropdown.filterInput.val(); + + if (this.multiSelect && inputValue === '') { + // Remove non-users from the fullData array + const users = glDropdown.filteredFullData(); + const callback = glDropdown.parseData.bind(glDropdown); + + // Update the data model + this.processData(inputValue, users, callback); + } - selectedUsers.forEach(selectedUser => { - showDivider += 1; - users.splice(showDivider, 0, selectedUser); - }); + if (this.multiSelect) { + return getMultiSelectDropdownTitle(selected, $(el).hasClass('is-active')); + } - users.splice(showDivider + 1, 0, { type: 'divider' }); - } - } - } + if (selected && 'id' in selected && $(el).hasClass('is-active')) { + $dropdown.find('.dropdown-toggle-text').removeClass('is-default'); + if (selected.text) { + return selected.text; + } else { + return selected.name; + } + } else { + $dropdown.find('.dropdown-toggle-text').addClass('is-default'); + return defaultLabel; + } + }, + defaultLabel, + hidden() { + if ($dropdown.hasClass('js-multiselect')) { + emitSidebarEvent('sidebar.saveAssignees'); + } - callback(users); - if (showMenuAbove) { - $dropdown.data('glDropdown').positionMenuAbove(); - } - }, - filterable: true, - filterRemote: true, - search: { - fields: ['name', 'username'], - }, - selectable: true, - fieldName: $dropdown.data('fieldName'), - toggleLabel(selected, el, glDropdown) { - const inputValue = glDropdown.filterInput.val(); - - if (this.multiSelect && inputValue === '') { - // Remove non-users from the fullData array - const users = glDropdown.filteredFullData(); - const callback = glDropdown.parseData.bind(glDropdown); - - // Update the data model - this.processData(inputValue, users, callback); - } + if (!$dropdown.data('alwaysShowSelectbox')) { + $selectbox.hide(); - if (this.multiSelect) { - return getMultiSelectDropdownTitle(selected, $(el).hasClass('is-active')); - } + // Recalculate where .value is because vue might have changed it + $block = $selectbox.closest('.block'); + $value = $block.find('.value'); + // display:block overrides the hide-collapse rule + $value.css('display', ''); + } + }, + multiSelect: $dropdown.hasClass('js-multiselect'), + inputMeta: $dropdown.data('inputMeta'), + clicked(options) { + const { $el, e, isMarking } = options; + const user = options.selectedObj; + + $el.tooltip('dispose'); + + if ($dropdown.hasClass('js-multiselect')) { + const isActive = $el.hasClass('is-active'); + const previouslySelected = $dropdown + .closest('.selectbox') + .find(`input[name='${$dropdown.data('fieldName')}'][value!=0]`); - if (selected && 'id' in selected && $(el).hasClass('is-active')) { - $dropdown.find('.dropdown-toggle-text').removeClass('is-default'); - if (selected.text) { - return selected.text; - } else { - return selected.name; - } - } else { - $dropdown.find('.dropdown-toggle-text').addClass('is-default'); - return defaultLabel; + // Enables support for limiting the number of users selected + // Automatically removes the first on the list if more users are selected + checkMaxSelect(); + + if (user.beforeDivider && user.name.toLowerCase() === 'unassigned') { + // Unassigned selected + previouslySelected.each((index, element) => { + element.remove(); + }); + emitSidebarEvent('sidebar.removeAllAssignees'); + } else if (isActive) { + // user selected + emitSidebarEvent('sidebar.addAssignee', user); + + // Remove unassigned selection (if it was previously selected) + const unassignedSelected = $dropdown + .closest('.selectbox') + .find(`input[name='${$dropdown.data('fieldName')}'][value=0]`); + + if (unassignedSelected) { + unassignedSelected.remove(); } - }, - defaultLabel, - hidden() { - if ($dropdown.hasClass('js-multiselect')) { - emitSidebarEvent('sidebar.saveAssignees'); + } else { + if (previouslySelected.length === 0) { + // Select unassigned because there is no more selected users + this.addInput($dropdown.data('fieldName'), 0, {}); } - if (!$dropdown.data('alwaysShowSelectbox')) { - $selectbox.hide(); + // User unselected + emitSidebarEvent('sidebar.removeAssignee', user); + } - // Recalculate where .value is because vue might have changed it - $block = $selectbox.closest('.block'); - $value = $block.find('.value'); - // display:block overrides the hide-collapse rule - $value.css('display', ''); - } - }, - multiSelect: $dropdown.hasClass('js-multiselect'), - inputMeta: $dropdown.data('inputMeta'), - clicked(options) { - const { $el, e, isMarking } = options; - const user = options.selectedObj; - - $el.tooltip('dispose'); - - if ($dropdown.hasClass('js-multiselect')) { - const isActive = $el.hasClass('is-active'); - const previouslySelected = $dropdown - .closest('.selectbox') - .find(`input[name='${$dropdown.data('fieldName')}'][value!=0]`); - - // Enables support for limiting the number of users selected - // Automatically removes the first on the list if more users are selected - checkMaxSelect(); - - if (user.beforeDivider && user.name.toLowerCase() === 'unassigned') { - // Unassigned selected - previouslySelected.each((index, element) => { - element.remove(); - }); - emitSidebarEvent('sidebar.removeAllAssignees'); - } else if (isActive) { - // user selected - emitSidebarEvent('sidebar.addAssignee', user); - - // Remove unassigned selection (if it was previously selected) - const unassignedSelected = $dropdown - .closest('.selectbox') - .find(`input[name='${$dropdown.data('fieldName')}'][value=0]`); - - if (unassignedSelected) { - unassignedSelected.remove(); - } - } else { - if (previouslySelected.length === 0) { - // Select unassigned because there is no more selected users - this.addInput($dropdown.data('fieldName'), 0, {}); - } + if (getSelected().find(u => u === gon.current_user_id)) { + $('.assign-to-me-link').hide(); + } else { + $('.assign-to-me-link').show(); + } + } - // User unselected - emitSidebarEvent('sidebar.removeAssignee', user); - } + const page = $('body').attr('data-page'); + const isIssueIndex = page === 'projects:issues:index'; + const isMRIndex = page === page && page === 'projects:merge_requests:index'; + if ( + $dropdown.hasClass('js-filter-bulk-update') || + $dropdown.hasClass('js-issuable-form-dropdown') + ) { + e.preventDefault(); - if (getSelected().find(u => u === gon.current_user_id)) { - $('.assign-to-me-link').hide(); - } else { - $('.assign-to-me-link').show(); - } - } + const isSelecting = user.id !== selectedId; + selectedId = isSelecting ? user.id : selectedIdDefault; - const page = $('body').attr('data-page'); - const isIssueIndex = page === 'projects:issues:index'; - const isMRIndex = page === page && page === 'projects:merge_requests:index'; - if ( - $dropdown.hasClass('js-filter-bulk-update') || - $dropdown.hasClass('js-issuable-form-dropdown') - ) { - e.preventDefault(); - - const isSelecting = user.id !== selectedId; - selectedId = isSelecting ? user.id : selectedIdDefault; - - if (selectedId === gon.current_user_id) { - $('.assign-to-me-link').hide(); - } else { - $('.assign-to-me-link').show(); - } - return; - } - if ($el.closest('.add-issues-modal').length) { - ModalStore.store.filter[$dropdown.data('fieldName')] = user.id; - } else if (handleClick) { - e.preventDefault(); - handleClick(user, isMarking); - } else if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) { - return Issuable.filterResults($dropdown.closest('form')); - } else if ($dropdown.hasClass('js-filter-submit')) { - return $dropdown.closest('form').submit(); - } else if (!$dropdown.hasClass('js-multiselect')) { - const selected = $dropdown - .closest('.selectbox') - .find(`input[name='${$dropdown.data('fieldName')}']`) - .val(); - return assignTo(selected); - } + if (selectedId === gon.current_user_id) { + $('.assign-to-me-link').hide(); + } else { + $('.assign-to-me-link').show(); + } + return; + } + if ($el.closest('.add-issues-modal').length) { + ModalStore.store.filter[$dropdown.data('fieldName')] = user.id; + } else if (handleClick) { + e.preventDefault(); + handleClick(user, isMarking); + } else if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) { + return Issuable.filterResults($dropdown.closest('form')); + } else if ($dropdown.hasClass('js-filter-submit')) { + return $dropdown.closest('form').submit(); + } else if (!$dropdown.hasClass('js-multiselect')) { + const selected = $dropdown + .closest('.selectbox') + .find(`input[name='${$dropdown.data('fieldName')}']`) + .val(); + return assignTo(selected); + } - // Automatically close dropdown after assignee is selected - // since CE has no multiple assignees - // EE does not have a max-select - if ( - $dropdown.data('maxSelect') && - getSelected().length === $dropdown.data('maxSelect') - ) { - // Close the dropdown - $dropdown.dropdown('toggle'); - } - }, - id(user) { - return user.id; - }, - opened(e) { - const $el = $(e.currentTarget); - const selected = getSelected(); - if ($dropdown.hasClass('js-issue-board-sidebar') && selected.length === 0) { - this.addInput($dropdown.data('fieldName'), 0, {}); - } - $el.find('.is-active').removeClass('is-active'); + // Automatically close dropdown after assignee is selected + // since CE has no multiple assignees + // EE does not have a max-select + if ($dropdown.data('maxSelect') && getSelected().length === $dropdown.data('maxSelect')) { + // Close the dropdown + $dropdown.dropdown('toggle'); + } + }, + id(user) { + return user.id; + }, + opened(e) { + const $el = $(e.currentTarget); + const selected = getSelected(); + if ($dropdown.hasClass('js-issue-board-sidebar') && selected.length === 0) { + this.addInput($dropdown.data('fieldName'), 0, {}); + } + $el.find('.is-active').removeClass('is-active'); - function highlightSelected(id) { - $el.find(`li[data-user-id="${id}"] .dropdown-menu-user-link`).addClass('is-active'); - } + function highlightSelected(id) { + $el.find(`li[data-user-id="${id}"] .dropdown-menu-user-link`).addClass('is-active'); + } - if (selected.length > 0) { - getSelected().forEach(selectedId => highlightSelected(selectedId)); - } else if ($dropdown.hasClass('js-issue-board-sidebar')) { - highlightSelected(0); - } else { - highlightSelected(selectedId); - } - }, - updateLabel: $dropdown.data('dropdownTitle'), - renderRow(user) { - const username = user.username ? `@${user.username}` : ''; - const avatar = user.avatar_url ? user.avatar_url : gon.default_avatar_url; + if (selected.length > 0) { + getSelected().forEach(selectedId => highlightSelected(selectedId)); + } else if ($dropdown.hasClass('js-issue-board-sidebar')) { + highlightSelected(0); + } else { + highlightSelected(selectedId); + } + }, + updateLabel: $dropdown.data('dropdownTitle'), + renderRow(user) { + const username = user.username ? `@${user.username}` : ''; + const avatar = user.avatar_url ? user.avatar_url : gon.default_avatar_url; - let selected = false; + let selected = false; - if (this.multiSelect) { - selected = getSelected().find(u => user.id === u); + if (this.multiSelect) { + selected = getSelected().find(u => user.id === u); - const { fieldName } = this; - const field = $dropdown - .closest('.selectbox') - .find(`input[name='${fieldName}'][value='${user.id}']`); + const { fieldName } = this; + const field = $dropdown + .closest('.selectbox') + .find(`input[name='${fieldName}'][value='${user.id}']`); - if (field.length) { - selected = true; - } - } else { - selected = user.id === selectedId; - } + if (field.length) { + selected = true; + } + } else { + selected = user.id === selectedId; + } - let img = ''; - if (user.beforeDivider != null) { - `<li><a href='#' class='${selected === true ? 'is-active' : ''}'>${_.escape( - user.name, - )}</a></li>`; - } else { - // 0 margin, because it's now handled by a wrapper - img = `<img src='${avatar}' class='avatar avatar-inline m-0' width='32' />`; - } + let img = ''; + if (user.beforeDivider != null) { + `<li><a href='#' class='${selected === true ? 'is-active' : ''}'>${_.escape( + user.name, + )}</a></li>`; + } else { + // 0 margin, because it's now handled by a wrapper + img = `<img src='${avatar}' class='avatar avatar-inline m-0' width='32' />`; + } - return _this.renderRow(options.issuableType, user, selected, username, img); - }, - }); - }; - })(this), - ); + return userSelect.renderRow(options.issuableType, user, selected, username, img); + }, + }); + }); import(/* webpackChunkName: 'select2' */ 'select2/select2') .then(() => { - $('.ajax-users-select').each( - (function(_this) { - return function(i, select) { - const options = {}; - options.skipLdap = $(select).hasClass('skip_ldap'); - options.projectId = $(select).data('projectId'); - options.groupId = $(select).data('groupId'); - options.showCurrentUser = $(select).data('currentUser'); - options.authorId = $(select).data('authorId'); - options.skipUsers = $(select).data('skipUsers'); - const showNullUser = $(select).data('nullUser'); - const showAnyUser = $(select).data('anyUser'); - const showEmailUser = $(select).data('emailUser'); - const firstUser = $(select).data('firstUser'); - return $(select).select2({ - placeholder: __('Search for a user'), - multiple: $(select).hasClass('multiselect'), - minimumInputLength: 0, - query(query) { - return _this.users(query.term, options, users => { - let name; - const data = { - results: users, - }; - if (query.term.length === 0) { - if (firstUser) { - // Move current user to the front of the list - const ref = data.results; - - for (let index = 0, len = ref.length; index < len; index += 1) { - const obj = ref[index]; - if (obj.username === firstUser) { - data.results.splice(index, 1); - data.results.unshift(obj); - break; - } - } - } - if (showNullUser) { - const nullUser = { - name: s__('UsersSelect|Unassigned'), - id: 0, - }; - data.results.unshift(nullUser); - } - if (showAnyUser) { - name = showAnyUser; - if (name === true) { - name = s__('UsersSelect|Any User'); - } - const anyUser = { - name, - id: null, - }; - data.results.unshift(anyUser); + $('.ajax-users-select').each((i, select) => { + const options = {}; + options.skipLdap = $(select).hasClass('skip_ldap'); + options.projectId = $(select).data('projectId'); + options.groupId = $(select).data('groupId'); + options.showCurrentUser = $(select).data('currentUser'); + options.authorId = $(select).data('authorId'); + options.skipUsers = $(select).data('skipUsers'); + const showNullUser = $(select).data('nullUser'); + const showAnyUser = $(select).data('anyUser'); + const showEmailUser = $(select).data('emailUser'); + const firstUser = $(select).data('firstUser'); + return $(select).select2({ + placeholder: __('Search for a user'), + multiple: $(select).hasClass('multiselect'), + minimumInputLength: 0, + query(query) { + return userSelect.users(query.term, options, users => { + let name; + const data = { + results: users, + }; + if (query.term.length === 0) { + if (firstUser) { + // Move current user to the front of the list + const ref = data.results; + + for (let index = 0, len = ref.length; index < len; index += 1) { + const obj = ref[index]; + if (obj.username === firstUser) { + data.results.splice(index, 1); + data.results.unshift(obj); + break; } } - if ( - showEmailUser && - data.results.length === 0 && - query.term.match(/^[^@]+@[^@]+$/) - ) { - const trimmed = query.term.trim(); - const emailUser = { - name: sprintf(__('Invite "%{trimmed}" by email'), { trimmed }), - username: trimmed, - id: trimmed, - invite: true, - }; - data.results.unshift(emailUser); + } + if (showNullUser) { + const nullUser = { + name: s__('UsersSelect|Unassigned'), + id: 0, + }; + data.results.unshift(nullUser); + } + if (showAnyUser) { + name = showAnyUser; + if (name === true) { + name = s__('UsersSelect|Any User'); } - return query.callback(data); - }); - }, - initSelection() { - const args = 1 <= arguments.length ? [].slice.call(arguments, 0) : []; - return _this.initSelection.apply(_this, args); - }, - formatResult() { - const args = 1 <= arguments.length ? [].slice.call(arguments, 0) : []; - return _this.formatResult.apply(_this, args); - }, - formatSelection() { - const args = 1 <= arguments.length ? [].slice.call(arguments, 0) : []; - return _this.formatSelection.apply(_this, args); - }, - dropdownCssClass: 'ajax-users-dropdown', - // we do not want to escape markup since we are displaying html in results - escapeMarkup(m) { - return m; - }, + const anyUser = { + name, + id: null, + }; + data.results.unshift(anyUser); + } + } + if (showEmailUser && data.results.length === 0 && query.term.match(/^[^@]+@[^@]+$/)) { + const trimmed = query.term.trim(); + const emailUser = { + name: sprintf(__('Invite "%{trimmed}" by email'), { trimmed }), + username: trimmed, + id: trimmed, + invite: true, + }; + data.results.unshift(emailUser); + } + return query.callback(data); }); - }; - })(this), - ); + }, + initSelection() { + const args = 1 <= arguments.length ? [].slice.call(arguments, 0) : []; + return userSelect.initSelection.apply(userSelect, args); + }, + formatResult() { + const args = 1 <= arguments.length ? [].slice.call(arguments, 0) : []; + return userSelect.formatResult.apply(userSelect, args); + }, + formatSelection() { + const args = 1 <= arguments.length ? [].slice.call(arguments, 0) : []; + return userSelect.formatSelection.apply(userSelect, args); + }, + dropdownCssClass: 'ajax-users-dropdown', + // we do not want to escape markup since we are displaying html in results + escapeMarkup(m) { + return m; + }, + }); + }); }) .catch(() => {}); } diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment.vue index e03b1e6d6a6850a99249bd4a1dbc8595e3d7bb69..34866cdfa6f425a54d28bead52eddd4ab1fa8158 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment.vue @@ -1,5 +1,5 @@ <script> -import { GlTooltipDirective } from '@gitlab/ui'; +import { __, s__ } from '~/locale'; import DeploymentInfo from './deployment_info.vue'; import DeploymentViewButton from './deployment_view_button.vue'; import DeploymentStopButton from './deployment_stop_button.vue'; @@ -14,9 +14,6 @@ export default { DeploymentStopButton, DeploymentViewButton, }, - directives: { - GlTooltip: GlTooltipDirective, - }, props: { deployment: { type: Object, @@ -43,6 +40,14 @@ export default { }, }, computed: { + appButtonText() { + return { + text: this.isCurrent ? s__('Review App|View app') : s__('Review App|View latest app'), + tooltip: this.isCurrent + ? '' + : __('View the latest successful deployment to this environment'), + }; + }, canBeManuallyDeployed() { return this.computedDeploymentStatus === MANUAL_DEPLOY; }, @@ -55,9 +60,6 @@ export default { hasExternalUrls() { return Boolean(this.deployment.external_url && this.deployment.external_url_formatted); }, - hasPreviousDeployment() { - return Boolean(!this.isCurrent && this.deployment.deployed_at); - }, isCurrent() { return this.computedDeploymentStatus === SUCCESS; }, @@ -89,7 +91,7 @@ export default { <!-- show appropriate version of review app button --> <deployment-view-button v-if="hasExternalUrls" - :is-current="isCurrent" + :app-button-text="appButtonText" :deployment="deployment" :show-visual-review-app="showVisualReviewApp" :visual-review-app-metadata="visualReviewAppMeta" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue index 9965e3d520339624d3a36f52a723ff28d42d6bb8..18d4073ecd48fe09b8666ba6c68464002ef78491 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue @@ -11,12 +11,12 @@ export default { import('ee_component/vue_merge_request_widget/components/visual_review_app_link.vue'), }, props: { - deployment: { + appButtonText: { type: Object, required: true, }, - isCurrent: { - type: Boolean, + deployment: { + type: Object, required: true, }, showVisualReviewApp: { @@ -60,7 +60,7 @@ export default { > <template slot="mainAction" slot-scope="slotProps"> <review-app-link - :is-current="isCurrent" + :display="appButtonText" :link="deploymentExternalUrl" :css-class="`deploy-link js-deploy-url inline ${slotProps.className}`" /> @@ -85,7 +85,7 @@ export default { </filtered-search-dropdown> <template v-else> <review-app-link - :is-current="isCurrent" + :display="appButtonText" :link="deploymentExternalUrl" css-class="js-deploy-url deploy-link btn btn-default btn-sm inline" /> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue index 36f291e995cc5456c6d0198fb9696e3d1cb4f8d9..96603d2337447c6bc2bef672f3953acd88acb6de 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue @@ -1,12 +1,11 @@ <script> -import { GlButton, GlLink, GlLoadingIcon } from '@gitlab/ui'; +import { GlButton, GlLoadingIcon } from '@gitlab/ui'; import { __ } from '~/locale'; import Icon from '~/vue_shared/components/icon.vue'; export default { components: { GlButton, - GlLink, GlLoadingIcon, Icon, }, diff --git a/app/assets/javascripts/vue_merge_request_widget/components/review_app_link.vue b/app/assets/javascripts/vue_merge_request_widget/components/review_app_link.vue index 1550ec0f21e14010e96d161a0a1f9468fafd27c5..c38c41f13b66ffd64a9ab69e5e4aae82a1232f46 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/review_app_link.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/review_app_link.vue @@ -1,18 +1,21 @@ <script> -import { __ } from '~/locale'; +import { GlTooltipDirective } from '@gitlab/ui'; import Icon from '~/vue_shared/components/icon.vue'; export default { components: { Icon, }, + directives: { + GlTooltip: GlTooltipDirective, + }, props: { cssClass: { type: String, required: true, }, - isCurrent: { - type: Boolean, + display: { + type: Object, required: true, }, link: { @@ -20,15 +23,12 @@ export default { required: true, }, }, - computed: { - linkText() { - return this.isCurrent ? __('View app') : __('View previous app'); - }, - }, }; </script> <template> <a + v-gl-tooltip + :title="display.tooltip" :href="link" target="_blank" rel="noopener noreferrer nofollow" @@ -36,6 +36,6 @@ export default { data-track-event="open_review_app" data-track-label="review_app" > - {{ linkText }} <icon class="fgray" name="external-link" /> + {{ display.text }} <icon class="fgray" name="external-link" /> </a> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue index 11bc8c73ee94afd375d3a7a0cc836b24e65a0e16..75d1e5865b0d94efd7e1f1c6db94e0575cde400a 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue @@ -84,7 +84,12 @@ export default { <span v-else> {{ s__('mrWidget|Merge failed.') }} </span> <span :class="{ 'has-custom-error': mr.mergeError }"> {{ timerText }} </span> </span> - <button class="btn btn-default btn-sm js-refresh-button" type="button" @click="refresh"> + <button + class="btn btn-default btn-sm js-refresh-button" + data-qa-selector="merge_request_error_content" + type="button" + @click="refresh" + > {{ s__('mrWidget|Refresh now') }} </button> </div> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue index e9aac8b385c0553a588eee27af53fe2f2441710b..8f38ca69453eb48d1b02754b37ffa8d6d58d20c6 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue @@ -2,7 +2,6 @@ import { sprintf, s__ } from '~/locale'; import tooltip from '~/vue_shared/directives/tooltip'; import statusIcon from '../mr_widget_status_icon.vue'; -import mrWidgetMergeHelp from '../../components/mr_widget_merge_help.vue'; export default { name: 'MRWidgetMissingBranch', @@ -10,7 +9,6 @@ export default { tooltip, }, components: { - mrWidgetMergeHelp, statusIcon, }, props: { diff --git a/app/assets/javascripts/vue_shared/components/changed_file_icon.vue b/app/assets/javascripts/vue_shared/components/changed_file_icon.vue index 75c3c544c77e6adc282cbddd0cdbc466cead3402..09cffc57688f9d6b3fb053ae9174b44195bc44eb 100644 --- a/app/assets/javascripts/vue_shared/components/changed_file_icon.vue +++ b/app/assets/javascripts/vue_shared/components/changed_file_icon.vue @@ -36,12 +36,17 @@ export default { required: false, default: true, }, + showChangedStatus: { + type: Boolean, + required: false, + default: false, + }, }, computed: { changedIcon() { // False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26 // eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings - const suffix = !this.file.changed && this.file.staged && this.showStagedIcon ? '-solid' : ''; + const suffix = this.showStagedIcon ? '-solid' : ''; return `${getCommitIconMap(this.file).icon}${suffix}`; }, @@ -86,8 +91,8 @@ export default { <span v-gl-tooltip.right :title="tooltipTitle" - :class="{ 'ml-auto': isCentered }" - class="file-changed-icon d-inline-block" + :class="[{ 'ml-auto': isCentered }, changedIconClass]" + class="file-changed-icon d-flex align-items-center " > <icon v-if="showIcon" :name="changedIcon" :size="size" :class="changedIconClass" /> </span> diff --git a/app/assets/javascripts/vue_shared/components/file_row.vue b/app/assets/javascripts/vue_shared/components/file_row.vue index 611001df32f9b7c7ef7929b4505ffda7502f5a6a..0c9f6ea94d5ab888b1d52339b35fede54de5f486 100644 --- a/app/assets/javascripts/vue_shared/components/file_row.vue +++ b/app/assets/javascripts/vue_shared/components/file_row.vue @@ -1,5 +1,4 @@ <script> -import Icon from '~/vue_shared/components/icon.vue'; import FileHeader from '~/vue_shared/components/file_row_header.vue'; import FileIcon from '~/vue_shared/components/file_icon.vue'; import ChangedFileIcon from '~/vue_shared/components/changed_file_icon.vue'; @@ -9,7 +8,6 @@ export default { components: { FileHeader, FileIcon, - Icon, ChangedFileIcon, }, props: { @@ -26,6 +24,7 @@ export default { required: false, default: null, }, + hideExtraOnTree: { type: Boolean, required: false, @@ -143,17 +142,17 @@ export default { @mouseleave="toggleDropdown(false)" > <div class="file-row-name-container"> - <span ref="textOutput" :style="levelIndentation" class="file-row-name str-truncated"> + <span ref="textOutput" :style="levelIndentation" class="file-row-name str-truncated d-flex"> <file-icon v-if="!showChangedIcon || file.type === 'tree'" - class="file-row-icon" + class="file-row-icon text-secondary mr-1" :file-name="file.name" :loading="file.loading" :folder="isTree" :opened="file.opened" :size="16" /> - <changed-file-icon v-else :file="file" :size="16" class="append-right-5" /> + <file-icon v-else :file-name="file.name" :size="16" css-classes="top mr-1" /> {{ file.name }} </span> <component @@ -163,6 +162,7 @@ export default { :dropdown-open="dropdownOpen" @toggle="toggleDropdown($event)" /> + <changed-file-icon :file="file" :size="16" class="append-right-5" /> </div> </div> <template v-if="file.opened || file.isHeader"> @@ -172,7 +172,6 @@ export default { :file="childFile" :level="childFilesLevel" :hide-extra-on-tree="hideExtraOnTree" - :extra-component="extraComponent" :show-changed-icon="showChangedIcon" @toggleTreeOpen="toggleTreeOpen" @clickFile="clickedFile" diff --git a/app/assets/javascripts/vue_shared/components/header_ci_component.vue b/app/assets/javascripts/vue_shared/components/header_ci_component.vue index c652a684d7ce8dff7b210913ef902e6b6d371349..dba4a9231a1704b5fa4e6c7aa1055939f53668a6 100644 --- a/app/assets/javascripts/vue_shared/components/header_ci_component.vue +++ b/app/assets/javascripts/vue_shared/components/header_ci_component.vue @@ -117,28 +117,7 @@ export default { <section v-if="actions.length" class="header-action-buttons"> <template v-for="(action, i) in actions"> - <gl-link - v-if="action.type === 'link'" - :key="i" - :href="action.path" - :class="action.cssClass" - > - {{ action.label }} - </gl-link> - - <gl-link - v-else-if="action.type === 'ujs-link'" - :key="i" - :href="action.path" - :class="action.cssClass" - data-method="post" - rel="nofollow" - > - {{ action.label }} - </gl-link> - <loading-button - v-else-if="action.type === 'button'" :key="i" :loading="action.isLoading" :disabled="action.isLoading" diff --git a/app/assets/javascripts/vue_shared/components/lib/utils/diff_utils.js b/app/assets/javascripts/vue_shared/components/lib/utils/diff_utils.js index d1aba99ac22f986b4a68c244feab3db7ad9fc536..188ab1769a4edb8561c48db6065029fcf9523084 100644 --- a/app/assets/javascripts/vue_shared/components/lib/utils/diff_utils.js +++ b/app/assets/javascripts/vue_shared/components/lib/utils/diff_utils.js @@ -12,6 +12,7 @@ function cleanSuggestionLine(line = {}) { return { ...line, text: trimFirstCharOfLineContent(line.text), + rich_text: trimFirstCharOfLineContent(line.rich_text), }; } diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue index 326440f5013cf1e55ee4fb4c34adf2c55a87a081..4f5f3ee5cf9685a6720cfb0ea35cf006b6ce7121 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/field.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue @@ -20,6 +20,11 @@ export default { Suggestions, }, props: { + isSubmitting: { + type: Boolean, + required: false, + default: false, + }, markdownPreviewPath: { type: String, required: false, @@ -133,6 +138,20 @@ export default { ); }, }, + watch: { + isSubmitting(isSubmitting) { + if (!isSubmitting || !this.$refs['markdown-preview'].querySelectorAll) { + return; + } + const mediaInPreview = this.$refs['markdown-preview'].querySelectorAll('video, audio'); + + if (mediaInPreview) { + mediaInPreview.forEach(media => { + media.pause(); + }); + } + }, + }, mounted() { /* GLForm class handles all the toolbar buttons @@ -177,7 +196,6 @@ export default { this.renderMarkdown(); } }, - showWriteTab() { this.markdownPreview = ''; this.previewMarkdown = false; diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue index af4ac024e4f21d3d4e7cca5e76009c9d701840f7..fee5d6d5e3aebe6b77465a4b430a5ca8e8bcdf8f 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/header.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue @@ -136,7 +136,11 @@ export default { > <strong>{{ __('New! Suggest changes directly') }}</strong> <p class="mb-2"> - {{ __('Suggest code changes which are immediately applied. Try it out!') }} + {{ + __( + 'Suggest code changes which can be immediately applied in one click. Try it out!', + ) + }} </p> <gl-button variant="primary" size="sm" @click="handleSuggestDismissed"> {{ __('Got it') }} diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_row.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_row.vue index c09bdfec250bf0339ff05be578e7a0e2daad8921..97d93eaaf3f6b557c1253efbfdcbe923bba4c552 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_row.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_row.vue @@ -24,7 +24,8 @@ export default { {{ line.new_line }} </td> <td class="line_content" :class="lineType"> - <span v-if="line.text">{{ line.text }}</span> + <span v-if="line.rich_text" v-html="line.rich_text"></span> + <span v-else-if="line.text">{{ line.text }}</span> <!-- TODO: replace this hack with zero-width whitespace when we have rich_text from BE --> <span v-else>​</span> </td> diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue index 7f0fcfac0715940d2ae8233535ff2026ed1c3a99..20a14d78f9b5eea1d7b0a3bd3129f36e0f8b6ecc 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue @@ -5,7 +5,6 @@ import SuggestionDiff from './suggestion_diff.vue'; import Flash from '~/flash'; export default { - components: { SuggestionDiff }, props: { lineType: { type: String, 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 e61d1fd20312579b3811edb7f0047de722d90e6b..e75ac8c54bcdd7e19da83d6a289f4e7a1aaaea21 100644 --- a/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue +++ b/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue @@ -12,7 +12,7 @@ export default { </script> <template> - <timeline-entry-item class="note note-wrapper"> + <timeline-entry-item class="note note-wrapper" data-qa-selector="skeleton_note"> <div class="timeline-icon"></div> <div class="timeline-content"> <div class="note-header"></div> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_regular_label.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_regular_label.vue index 282b181f11e262fed57eb037f075372582278b85..f519f90445ed39dedb643ec218a7957e0501d9be 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_regular_label.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_regular_label.vue @@ -1,10 +1,9 @@ <script> -import { GlLink, GlTooltip } from '@gitlab/ui'; +import { GlTooltip } from '@gitlab/ui'; export default { components: { GlTooltip, - GlLink, }, props: { label: { diff --git a/app/assets/javascripts/vue_shared/components/split_button.vue b/app/assets/javascripts/vue_shared/components/split_button.vue index 9aacde492641b4160bded23b917c29a5c8df078a..f02b412e8a10ba6afe75bb0c5a2d4759048548bf 100644 --- a/app/assets/javascripts/vue_shared/components/split_button.vue +++ b/app/assets/javascripts/vue_shared/components/split_button.vue @@ -49,6 +49,10 @@ export default { triggerEvent() { this.$emit(this.selectedItem.eventName); }, + changeSelectedItem(item) { + this.selectedItem = item; + this.$emit('change', item); + }, }, }; </script> @@ -67,7 +71,7 @@ export default { :key="item.eventName" :active="selectedItem === item" active-class="is-active" - @click="selectedItem = item" + @click="changeSelectedItem(item)" > <strong>{{ item.title }}</strong> <div>{{ item.description }}</div> diff --git a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue index 4a72cca5f021e7491ce3d5500431bf5231710184..37e3643bf6c113503baf20fcace8ee86fffdb6f0 100644 --- a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue +++ b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue @@ -30,11 +30,16 @@ export default { }, computed: { statusHtml() { + if (!this.user.status) { + return ''; + } + if (this.user.status.emoji && this.user.status.message_html) { return `${glEmojiTag(this.user.status.emoji)} ${this.user.status.message_html}`; } else if (this.user.status.message_html) { return this.user.status.message_html; } + return ''; }, nameIsLoading() { @@ -97,7 +102,9 @@ export default { class="animation-container-small mb-1" /> </div> - <div v-if="user.status" class="mt-2"><span v-html="statusHtml"></span></div> + <div v-if="statusHtml" class="js-user-status mt-2"> + <span v-html="statusHtml"></span> + </div> </div> </div> </gl-popover> diff --git a/app/assets/javascripts/vuex_shared/bindings.js b/app/assets/javascripts/vuex_shared/bindings.js new file mode 100644 index 0000000000000000000000000000000000000000..817a90f814940fbe8cd49fe8265224d98b0d26ff --- /dev/null +++ b/app/assets/javascripts/vuex_shared/bindings.js @@ -0,0 +1,36 @@ +/** + * Returns computed properties two way bound to vuex + * + * @param {(string[]|Object[])} list - list of string matching state keys or list objects + * @param {string} list[].key - the key matching the key present in the vuex state + * @param {string} list[].getter - the name of the getter, leave it empty to not use a getter + * @param {string} list[].updateFn - the name of the action, leave it empty to use the default action + * @param {string} defaultUpdateFn - the default function to dispatch + * @param {string} root - the key of the state where to search fo they keys described in list + * @returns {Object} a dictionary with all the computed properties generated + */ +export const mapComputed = (list, defaultUpdateFn, root) => { + const result = {}; + list.forEach(item => { + const [getter, key, updateFn] = + typeof item === 'string' + ? [false, item, defaultUpdateFn] + : [item.getter, item.key, item.updateFn || defaultUpdateFn]; + result[key] = { + get() { + if (getter) { + return this.$store.getters[getter]; + } else if (root) { + return this.$store.state[root][key]; + } + return this.$store.state[key]; + }, + set(value) { + this.$store.dispatch(updateFn, { [key]: value }); + }, + }; + }); + return result; +}; + +export default () => {}; diff --git a/app/assets/stylesheets/test.scss b/app/assets/stylesheets/disable_animations.scss similarity index 100% rename from app/assets/stylesheets/test.scss rename to app/assets/stylesheets/disable_animations.scss diff --git a/app/assets/stylesheets/framework/broadcast_messages.scss b/app/assets/stylesheets/framework/broadcast_messages.scss index d3e7d751e63016e26cf31210fb0eddfd327b9791..95ea3d90a0e45b1964bfe9796375bb3652aab8f2 100644 --- a/app/assets/stylesheets/framework/broadcast_messages.scss +++ b/app/assets/stylesheets/framework/broadcast_messages.scss @@ -1,7 +1,5 @@ .broadcast-message { - @extend .alert-warning; - padding: 10px; - text-align: center; + padding: $gl-padding-8; div, p { @@ -15,9 +13,29 @@ } } -.broadcast-message-preview { +.broadcast-banner-message { + @extend .broadcast-message; + @extend .alert-warning; + text-align: center; +} + +.broadcast-notification-message { @extend .broadcast-message; - margin-bottom: 20px; + + position: fixed; + bottom: $gl-padding; + right: $gl-padding; + max-width: 300px; + width: auto; + background: $white-light; + border: 1px solid $gray-200; + box-shadow: 0 1px 2px 0 rgba($black, 0.1); + border-radius: $border-radius-default; + z-index: 999; + + &.preview { + position: static; + } } .toggle-colors { diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss index 767832e242c2c720231d7a09888ddb64091fe44a..1b549c0a4f0f42dd8a124dfd943dd2548b5e2980 100644 --- a/app/assets/stylesheets/framework/buttons.scss +++ b/app/assets/stylesheets/framework/buttons.scss @@ -29,7 +29,7 @@ &:focus, &:active { background-color: $btn-active-gray; - box-shadow: $gl-btn-active-background; + box-shadow: none; } } diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index 4b7dda3a2ffe2717fe73d985efa16ee55c4b1092..dc119b52f4e550833c97fd9ea73fe3285c8e662f 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -55,6 +55,10 @@ background-color: $gray-light; } +.bg-white { + background-color: $white; +} + .bg-line-target-blue { background: $line-target-blue; } @@ -456,6 +460,8 @@ img.emoji { .w-8em { width: 8em; } .w-3rem { width: 3rem; } .w-15p { width: 15%; } +.w-30p { width: 30%; } +.w-60p { width: 60%; } .w-70p { width: 70%; } .h-12em { height: 12em; } @@ -573,6 +579,7 @@ img.emoji { .gl-font-size-large { font-size: $gl-font-size-large; } .gl-line-height-24 { line-height: $gl-line-height-24; } +.gl-line-height-14 { line-height: $gl-line-height-14; } .gl-font-size-12 { font-size: $gl-font-size-12; } .gl-font-size-14 { font-size: $gl-font-size-14; } diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss index 8e0314bc6daf7df5f80bb62f7cebe431f3c829ae..1a017f03ebb590d2065d6e7d9e4641517dccc3a0 100644 --- a/app/assets/stylesheets/framework/files.scss +++ b/app/assets/stylesheets/framework/files.scss @@ -207,6 +207,14 @@ border-left-color: mix($blame-gray, $blame-cyan, $i / 4 * 100%); } } + + .doc-versions { + color: $gray-600; + + &:hover { + color: $gray-900; + } + } } &.logs { diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss index 1c2525840475cc5b98dd3f8e527ccaaf25c25231..b5d1c3f6732816f5597361acd0a5ddf7bf3b6675 100644 --- a/app/assets/stylesheets/framework/filters.scss +++ b/app/assets/stylesheets/framework/filters.scss @@ -88,6 +88,7 @@ } .name, + .operator, .value { display: inline-block; padding: 2px 7px; @@ -101,6 +102,12 @@ text-transform: capitalize; } + .operator { + background-color: $white-normal; + color: $filter-value-text-color; + margin-right: 1px; + } + .value-container { display: flex; align-items: center; @@ -147,6 +154,10 @@ background-color: $filter-name-selected-color; } + .operator { + box-shadow: inset 0 0 0 100px $filtered-search-term-shadow-color; + } + .value-container { box-shadow: inset 0 0 0 100px $filtered-search-term-shadow-color; } @@ -260,6 +271,11 @@ max-width: none; min-width: 100%; } + + .btn-helptext { + margin-left: auto; + color: var(--gray); + } } .filtered-search-history-dropdown-wrapper { diff --git a/app/assets/stylesheets/framework/highlight.scss b/app/assets/stylesheets/framework/highlight.scss index 58516cbd1a95e9195a8c867ba926650210f861f6..ee6e53adaf7f0b46087a2bb8b8be24490721e131 100644 --- a/app/assets/stylesheets/framework/highlight.scss +++ b/app/assets/stylesheets/framework/highlight.scss @@ -8,7 +8,7 @@ pre { padding: 10px 0; border: 0; - border-radius: 0 0 $border-radius-default $border-radius-default; + border-radius: 0 0 $border-radius-default; font-family: $monospace-font; font-size: $code-font-size; line-height: 1.5; diff --git a/app/assets/stylesheets/framework/icons.scss b/app/assets/stylesheets/framework/icons.scss index a53f5d859496b4c272e5f8ca30c1fcd4eba5f1c0..9ae313db4c1f254ab92c537686b6050c83ecfdea 100644 --- a/app/assets/stylesheets/framework/icons.scss +++ b/app/assets/stylesheets/framework/icons.scss @@ -20,6 +20,7 @@ } .ci-status-icon-pending, +.ci-status-icon-waiting-for-resource, .ci-status-icon-failed-with-warnings, .ci-status-icon-success-with-warnings { svg { diff --git a/app/assets/stylesheets/framework/job_log.scss b/app/assets/stylesheets/framework/job_log.scss index 4a57a458c50b3247b7bf6912024ebe960d13b619..fefc51bf1f742f1668c264f67e84d664c30ee078 100644 --- a/app/assets/stylesheets/framework/job_log.scss +++ b/app/assets/stylesheets/framework/job_log.scss @@ -22,6 +22,7 @@ min-width: $job-line-number-width; margin-left: -$job-line-number-margin; padding-right: 1em; + user-select: none; &:hover, &:active, diff --git a/app/assets/stylesheets/framework/lists.scss b/app/assets/stylesheets/framework/lists.scss index 4aba633e18242499994805b6251e6ee46438536f..738150dbd2e0f73c0f28657a11d425cb83fcdf99 100644 --- a/app/assets/stylesheets/framework/lists.scss +++ b/app/assets/stylesheets/framework/lists.scss @@ -101,6 +101,13 @@ ul.unstyled-list > li { border-bottom: 0; } +ul.list-items-py-2 { + > li { + padding-top: 0.5rem; + padding-bottom: 0.5rem; + } +} + // Generic content list ul.content-list { @include basic-list; diff --git a/app/assets/stylesheets/framework/mixins.scss b/app/assets/stylesheets/framework/mixins.scss index bf0f1da6aa3f67a27a40e72d06edce5655b1791d..d54648cc34bc50b9e721f5e2e0e85ce92af72800 100644 --- a/app/assets/stylesheets/framework/mixins.scss +++ b/app/assets/stylesheets/framework/mixins.scss @@ -271,18 +271,13 @@ } .btn-scroll.animate { - .first-triangle { - animation: blinking-scroll-button 1s ease infinite; - animation-delay: 0.3s; - } - - .second-triangle { - animation: blinking-scroll-button 1s ease infinite; - animation-delay: 0.2s; + .scroll-arrow { + animation: blinking-scroll-button 1.5s ease-in-out infinite; } - .third-triangle { - animation: blinking-scroll-button 1s ease infinite; + .scroll-dot { + animation: blinking-scroll-button 1.5s ease-in-out infinite; + animation-delay: 0.3s; } &:disabled { diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss index 39e7e4bb7e5ebdde56f1cb94a459edffa6d7a73e..a1bfa03a5ac4803c4a35280dcba3bca6dd1c9ece 100644 --- a/app/assets/stylesheets/framework/typography.scss +++ b/app/assets/stylesheets/framework/typography.scss @@ -179,15 +179,15 @@ tbody { background-color: $white-light; + + td { + border-color: $gray-200; + } } tr { th { - border-bottom: solid 2px $gl-gray-200; - } - - td { - border-color: $gl-gray-200; + border-bottom: solid 2px $gray-300; } } diff --git a/app/assets/stylesheets/framework/variables_overrides.scss b/app/assets/stylesheets/framework/variables_overrides.scss index 604b48e11aba76d72e08ecfb5f9402be32beb16a..7538459c97b6f1a5dc4b45adb5149dc59aa63332 100644 --- a/app/assets/stylesheets/framework/variables_overrides.scss +++ b/app/assets/stylesheets/framework/variables_overrides.scss @@ -12,6 +12,7 @@ $font-family-sans-serif: $regular-font; $font-family-monospace: $monospace-font; $btn-line-height: 20px; $table-accent-bg: $gray-light; +$table-border-color: $gray-200; $card-border-color: $border-color; $card-cap-bg: $gray-light; $success: $green-500; diff --git a/app/assets/stylesheets/page_bundles/ide.scss b/app/assets/stylesheets/page_bundles/ide.scss index 977fc8329b6ff381eed8092f2d8f74bd6d7ba525..420271c9a1e89c8b32b320a2e77335d7b08d2036 100644 --- a/app/assets/stylesheets/page_bundles/ide.scss +++ b/app/assets/stylesheets/page_bundles/ide.scss @@ -740,6 +740,7 @@ $ide-commit-header-height: 48px; .ide-sidebar-link { display: flex; align-items: center; + justify-content: center; position: relative; height: 60px; width: 100%; @@ -1076,10 +1077,12 @@ $ide-commit-header-height: 48px; } } -.ide-right-sidebar { +.ide-sidebar { width: auto; min-width: 60px; +} +.ide-right-sidebar { .ide-activity-bar { border-left: 1px solid $white-dark; } diff --git a/app/assets/stylesheets/pages/boards.scss b/app/assets/stylesheets/pages/boards.scss index 90c2e369ccd243195a8595d0d6906cc4874c4d2d..31e87d1a7cf1c419a19b38d4327618ec6f5ae55a 100644 --- a/app/assets/stylesheets/pages/boards.scss +++ b/app/assets/stylesheets/pages/boards.scss @@ -133,6 +133,7 @@ .issue-count-badge { border: 0; white-space: nowrap; + padding: 0; } .board-title-text > span, @@ -385,22 +386,19 @@ margin: 5px; } -.issue-boards-sidebar { +.right-sidebar.issue-boards-sidebar { .gutter-toggle { bottom: 15px; width: 22px; - color: $gray-darkest; + padding-left: $gl-padding-32; svg { position: absolute; top: 50%; + right: 0; margin-top: (-11px / 2); - } - - &:hover { - path { - fill: $gray-darkest; - } + height: $gl-font-size-12; + width: $gl-font-size-12; } } diff --git a/app/assets/stylesheets/pages/builds.scss b/app/assets/stylesheets/pages/builds.scss index c7d51a2093a661e402481f29a6301559dec9525e..0db90fc88fce8d0a6ca3d3d35ebb8a296a7b7993 100644 --- a/app/assets/stylesheets/pages/builds.scss +++ b/app/assets/stylesheets/pages/builds.scss @@ -32,16 +32,12 @@ opacity: 0.2; } - 25% { - opacity: 0.5; - } - 50% { - opacity: 0.7; + opacity: 1; } 100% { - opacity: 1; + opacity: 0.2; } } diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss index f394e4ab58a748c56f28b46b31f731768cef5865..d10535700934b9dface6cbe5ef825a63d7a737aa 100644 --- a/app/assets/stylesheets/pages/diff.scss +++ b/app/assets/stylesheets/pages/diff.scss @@ -14,9 +14,9 @@ cursor: pointer; @media (min-width: map-get($grid-breakpoints, md)) { - // The `-1` below is to prevent two borders from clashing up against eachother - + // The `+11` is to ensure the file header border shows when scrolled - // the bottom of the compare-versions header and the top of the file header - $mr-file-header-top: $mr-version-controls-height + $header-height + $mr-tabs-height - 1; + $mr-file-header-top: $mr-version-controls-height + $header-height + $mr-tabs-height + 11; position: -webkit-sticky; position: sticky; @@ -552,7 +552,7 @@ table.code { .diff-stats { align-items: center; - padding: 0 0.25rem; + padding: 0 1rem; .diff-stats-group { padding: 0 0.25rem; @@ -564,7 +564,7 @@ table.code { &.is-compare-versions-header { .diff-stats-group { - padding: 0 0.5rem; + padding: 0 0.25rem; } } } @@ -1059,8 +1059,8 @@ table.code { .diff-tree-list { position: -webkit-sticky; position: sticky; - $top-pos: $header-height + $mr-tabs-height + $mr-version-controls-height + 10px; - top: $header-height + $mr-tabs-height + $mr-version-controls-height + 10px; + $top-pos: $header-height + $mr-tabs-height + $mr-version-controls-height + 11px; + top: $header-height + $mr-tabs-height + $mr-version-controls-height + 11px; max-height: calc(100vh - #{$top-pos}); z-index: 202; @@ -1097,10 +1097,7 @@ table.code { .tree-list-scroll { max-height: 100%; - padding-top: $grid-size; padding-bottom: $grid-size; - border-top: 1px solid $border-color; - border-bottom: 1px solid $border-color; overflow-y: scroll; overflow-x: auto; } diff --git a/app/assets/stylesheets/pages/error_details.scss b/app/assets/stylesheets/pages/error_details.scss index dcd25c126c4d05ba02d09211ded5eef93b3152ec..61e2df7ea260acd3c54bd1cb5b67c6278c6fd203 100644 --- a/app/assets/stylesheets/pages/error_details.scss +++ b/app/assets/stylesheets/pages/error_details.scss @@ -2,6 +2,11 @@ li { @include gl-line-height-32; } + + .btn-outline-info { + color: $blue-500; + border-color: $blue-500; + } } .stacktrace { diff --git a/app/assets/stylesheets/pages/error_list.scss b/app/assets/stylesheets/pages/error_list.scss new file mode 100644 index 0000000000000000000000000000000000000000..f97953ce824f787dfc400ecdefe8b98e5aa80a63 --- /dev/null +++ b/app/assets/stylesheets/pages/error_list.scss @@ -0,0 +1,69 @@ +$gray-border: 1px solid $border-color; + +.error-list { + .sort-control { + .btn { + padding-right: 2rem; + } + + .gl-dropdown-caret { + position: absolute; + right: 0.5rem; + top: 0.5rem; + } + } + + @include media-breakpoint-up(sm) { + .row-top { + border: $gray-border; + background-color: $gray-50; + } + } + + @include media-breakpoint-down(xs) { + .table-row { + border: $gray-border; + border-radius: 4px; + } + + .search-box { + border-top: $gray-border; + border-bottom: $gray-border; + background-color: $gray-50; + } + + .table-col { + min-height: 68px; + + &::before { + text-align: left !important; + } + + &:first-child { + div { + padding: 0 !important; + align-items: flex-end; + } + } + + &:last-child { + height: 64px; + background-color: $gray-normal; + + &::before { + content: none !important; + } + + div { + width: 100% !important; + padding: 0 !important; + + a { + color: $blue-500; + border-color: $blue-500; + } + } + } + } + } +} diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index 09b335f9ba220d87662c236106a39f7a6b9ba7d8..43636f65eb8fe5e83b3de699610bb81329714faf 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -173,6 +173,20 @@ margin-top: 7px; } + .gutter-toggle { + margin-left: 20px; + padding-left: 10px; + + &:hover { + color: $gl-text-color; + } + + &:hover, + &:focus { + text-decoration: none; + } + } + .block { @include clearfix; padding: $gl-padding 0; @@ -195,20 +209,6 @@ margin-top: 0; } - .gutter-toggle { - margin-left: 20px; - padding-left: 10px; - - &:hover { - color: $gl-text-color; - } - - &:hover, - &:focus { - text-decoration: none; - } - } - &.assignee { .author-link { display: block; @@ -288,7 +288,7 @@ } .issuable-sidebar { - width: calc(100% + 100px); + width: 100%; height: 100%; overflow-y: scroll; overflow-x: hidden; diff --git a/app/assets/stylesheets/pages/members.scss b/app/assets/stylesheets/pages/members.scss index ae92a2fbd7b93ae0a719ebd8a538d499d5cac2f7..54bca80194f818de16ed300d3dd627753e0c5835 100644 --- a/app/assets/stylesheets/pages/members.scss +++ b/app/assets/stylesheets/pages/members.scss @@ -3,7 +3,7 @@ border-bottom: 1px solid $border-color; } -.users-project-form { +.invite-users-form { .btn-success { margin-right: 10px; } diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index c023c9e5cbdbdff064baedc11e4ca30c5f2b848a..84daec4fb43dca77e710563f98a7fa34efd4a28b 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -708,7 +708,7 @@ .mr-version-controls { position: relative; z-index: 203; - background: $gray-light; + background: $white-light; color: $gl-text-color; margin-top: -1px; @@ -732,7 +732,7 @@ } .content-block { - padding: $gl-padding-top $gl-padding; + padding: $gl-padding; border-bottom: 0; } diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss index 364fe3da71eca3184b8223d947575db92a738f88..82bef91230ebe42f3d4e2ac8e9c1cb23127f02c0 100644 --- a/app/assets/stylesheets/pages/pipelines.scss +++ b/app/assets/stylesheets/pages/pipelines.scss @@ -795,6 +795,7 @@ } &.ci-status-icon-pending, + &.ci-status-icon-waiting-for-resource, &.ci-status-icon-success-with-warnings { @include mini-pipeline-graph-color($white, $orange-100, $orange-200, $orange-500, $orange-600, $orange-700); } @@ -1092,3 +1093,7 @@ button.mini-pipeline-graph-dropdown-toggle { .progress-bar.bg-primary { background-color: $blue-500 !important; } + +.parent-child-label-container { + padding-top: $gl-padding-4; +} diff --git a/app/assets/stylesheets/pages/status.scss b/app/assets/stylesheets/pages/status.scss index 5d6a4b7cd13193cf90e06538a8fa2f4489b0f47b..4f3d6fb0d4469ca0551a109e83069489edd35816 100644 --- a/app/assets/stylesheets/pages/status.scss +++ b/app/assets/stylesheets/pages/status.scss @@ -42,6 +42,7 @@ } &.ci-pending, + &.ci-waiting-for-resource, &.ci-failed-with-warnings, &.ci-success-with-warnings { @include status-color($orange-100, $orange-500, $orange-700); diff --git a/app/assets/stylesheets/snippets.scss b/app/assets/stylesheets/snippets.scss index bd777c66b56ff95ff9856147d42b62c42c3e84f8..0008a0e5c517a26856050b276e6100fa9a85f25d 100644 --- a/app/assets/stylesheets/snippets.scss +++ b/app/assets/stylesheets/snippets.scss @@ -11,7 +11,7 @@ line-height: $code-line-height; color: $gl-text-color; margin: 20px; - font-weight: 200; + font-weight: $gl-font-weight-normal; .gl-snippet-icon { display: inline-block; @@ -34,7 +34,7 @@ .file-content.code { border: $border-style; - border-radius: 0 0 4px 4px; + border-radius: 0 0 $border-radius-default $border-radius-default; display: flex; box-shadow: none; margin: 0; @@ -45,12 +45,10 @@ overflow-x: auto; pre { + height: 100%; padding: 10px; border: 0; border-radius: 0; - font-family: $monospace-font; - font-size: $code-font-size; - line-height: $code-line-height; margin: 0; overflow: auto; overflow-y: hidden; @@ -58,6 +56,12 @@ word-wrap: normal; border-left: $border-style; } + + code { + font-family: $monospace-font; + font-size: $code-font-size; + line-height: $code-line-height; + } } .line-numbers { @@ -107,17 +111,13 @@ } } - .gitlab-logo { - display: inline-block; - padding-left: 5px; - text-decoration: none; - color: $gl-text-color-secondary; + .gitlab-logo-wrapper { + padding-left: $gl-padding-8; + position: relative; + top: 2px; - .logo-text { - background: image_url('ext_snippet_icons/logo.png') no-repeat left center; - background-size: 18px; - font-weight: $gl-font-weight-normal; - padding-left: 24px; + .gitlab-logo { + height: 18px; } } } @@ -125,7 +125,7 @@ img, .gl-snippet-icon { display: inline-block; - vertical-align: middle; + vertical-align: text-bottom; } } @@ -133,7 +133,7 @@ a.btn { background-color: $white-light; text-decoration: none; - padding: 7px 9px; + padding: 8px 9px; border: $border-style; border-right: 0; @@ -144,11 +144,11 @@ } &:first-child { - border-radius: 3px 0 0 3px; + border-radius: $border-radius-default 0 0 $border-radius-default; } &:last-child { - border-radius: 0 3px 3px 0; + border-radius: 0 $border-radius-default $border-radius-default 0; border-right: $border-style; } } diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss index 1f4bba5fc33a4772eb5fd3acbb14cc6c4fffd0e9..1517015dda07fbffe2ea01bfe4c76e3f2ed0a8cc 100644 --- a/app/assets/stylesheets/utilities.scss +++ b/app/assets/stylesheets/utilities.scss @@ -22,11 +22,20 @@ } } +@each $index, $size in $type-scale { + #{'.lh-#{$index}'} { + line-height: $size; + } +} + .border-width-1px { border-width: 1px; } +.border-bottom-width-1px { border-bottom-width: 1px; } .border-style-dashed { border-style: dashed; } .border-style-solid { border-style: solid; } +.border-bottom-style-solid { border-bottom-style: solid; } .border-color-blue-300 { border-color: $blue-300; } .border-color-default { border-color: $border-color; } +.border-bottom-color-default { border-bottom-color: $border-color; } .box-shadow-default { box-shadow: 0 2px 4px 0 $black-transparent; } .mh-50vh { max-height: 50vh; } diff --git a/app/assets/stylesheets/vendors/atwho.scss b/app/assets/stylesheets/vendors/atwho.scss index ccf3824ea56d918b5450d69b3c18b9df33b0f930..37ef52f9573acbd3220e6eb82af1a2a1224c788e 100644 --- a/app/assets/stylesheets/vendors/atwho.scss +++ b/app/assets/stylesheets/vendors/atwho.scss @@ -23,9 +23,9 @@ } .has-warning { - .name, .description { color: $orange-700; + background-color: $orange-100; } } @@ -59,7 +59,6 @@ &.has-warning { color: $orange-700; - background-color: $orange-100; } } diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb index 9d81d3fad079da7a77b33e505f748db1219b8962..3047ee02680dd4dd156efe07a379214d9521d1e6 100644 --- a/app/controllers/admin/application_settings_controller.rb +++ b/app/controllers/admin/application_settings_controller.rb @@ -5,11 +5,28 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController before_action :set_application_setting before_action :whitelist_query_limiting, only: [:usage_data] + before_action :validate_self_monitoring_feature_flag_enabled, only: [ + :create_self_monitoring_project, + :status_create_self_monitoring_project, + :delete_self_monitoring_project, + :status_delete_self_monitoring_project + ] + + before_action do + push_frontend_feature_flag(:self_monitoring_project) + end VALID_SETTING_PANELS = %w(general integrations repository ci_cd reporting metrics_and_profiling network preferences).freeze + # The current size of a sidekiq job's jid is 24 characters. The size of the + # jid is an internal detail of Sidekiq, and they do not guarantee that it'll + # stay the same. We chose 50 to give us room in case the size of the jid + # increases. The jid is alphanumeric, so 50 is very generous. There is a spec + # that ensures that the constant value is more than the size of an actual jid. + PARAM_JOB_ID_MAX_SIZE = 50 + VALID_SETTING_PANELS.each do |action| define_method(action) { perform_update if submitted? } end @@ -62,8 +79,103 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController redirect_to ::Gitlab::LetsEncrypt.terms_of_service_url end + def create_self_monitoring_project + job_id = SelfMonitoringProjectCreateWorker.perform_async + + render status: :accepted, json: { + job_id: job_id, + monitor_status: status_create_self_monitoring_project_admin_application_settings_path + } + end + + def status_create_self_monitoring_project + job_id = params[:job_id].to_s + + unless job_id.length <= PARAM_JOB_ID_MAX_SIZE + return render status: :bad_request, json: { + message: _('Parameter "job_id" cannot exceed length of %{job_id_max_size}' % + { job_id_max_size: PARAM_JOB_ID_MAX_SIZE }) + } + end + + if Gitlab::CurrentSettings.instance_administration_project_id.present? + return render status: :ok, json: self_monitoring_data + + elsif SelfMonitoringProjectCreateWorker.in_progress?(job_id) + ::Gitlab::PollingInterval.set_header(response, interval: 3_000) + + return render status: :accepted, json: { + message: _('Job to create self-monitoring project is in progress') + } + end + + render status: :bad_request, json: { + message: _('Self-monitoring project does not exist. Please check logs ' \ + 'for any error messages') + } + end + + def delete_self_monitoring_project + job_id = SelfMonitoringProjectDeleteWorker.perform_async + + render status: :accepted, json: { + job_id: job_id, + monitor_status: status_delete_self_monitoring_project_admin_application_settings_path + } + end + + def status_delete_self_monitoring_project + job_id = params[:job_id].to_s + + unless job_id.length <= PARAM_JOB_ID_MAX_SIZE + return render status: :bad_request, json: { + message: _('Parameter "job_id" cannot exceed length of %{job_id_max_size}' % + { job_id_max_size: PARAM_JOB_ID_MAX_SIZE }) + } + end + + if Gitlab::CurrentSettings.instance_administration_project_id.nil? + return render status: :ok, json: { + message: _('Self-monitoring project has been successfully deleted') + } + + elsif SelfMonitoringProjectDeleteWorker.in_progress?(job_id) + ::Gitlab::PollingInterval.set_header(response, interval: 3_000) + + return render status: :accepted, json: { + message: _('Job to delete self-monitoring project is in progress') + } + end + + render status: :bad_request, json: { + message: _('Self-monitoring project was not deleted. Please check logs ' \ + 'for any error messages') + } + end + private + def validate_self_monitoring_feature_flag_enabled + self_monitoring_project_not_implemented unless Feature.enabled?(:self_monitoring_project) + end + + def self_monitoring_data + { + project_id: Gitlab::CurrentSettings.instance_administration_project_id, + project_full_path: Gitlab::CurrentSettings.instance_administration_project&.full_path + } + end + + def self_monitoring_project_not_implemented + render( + status: :not_implemented, + json: { + message: _('Self-monitoring is not enabled on this GitLab server, contact your administrator.'), + documentation_url: help_page_path('administration/monitoring/gitlab_instance_administration_project/index') + } + ) + end + def set_application_setting @application_setting = ApplicationSetting.current_without_cache end diff --git a/app/controllers/admin/system_info_controller.rb b/app/controllers/admin/system_info_controller.rb index 244fc2b31bb0096e89a78c27ff4959849ec1a787..657aa177ecf4aa91c43240c2e71c94332eadec92 100644 --- a/app/controllers/admin/system_info_controller.rb +++ b/app/controllers/admin/system_info_controller.rb @@ -1,10 +1,10 @@ # frozen_string_literal: true class Admin::SystemInfoController < Admin::ApplicationController - EXCLUDED_MOUNT_OPTIONS = [ - 'nobrowse', - 'read-only', - 'ro' + EXCLUDED_MOUNT_OPTIONS = %w[ + nobrowse + read-only + ro ].freeze EXCLUDED_MOUNT_TYPES = [ diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index f5306801c049f4a695581af8ab2bc8051d361554..60b5d9b6da8352a158e06b91f19cd4cac4608de1 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -33,6 +33,7 @@ class ApplicationController < ActionController::Base before_action :check_impersonation_availability before_action :required_signup_info + around_action :set_current_context around_action :set_locale around_action :set_session_storage @@ -448,6 +449,14 @@ class ApplicationController < ActionController::Base request.base_url end + def set_current_context(&block) + Gitlab::ApplicationContext.with_context( + user: -> { auth_user }, + project: -> { @project }, + namespace: -> { @group }, + &block) + end + def set_locale(&block) Gitlab::I18n.with_user_locale(current_user, &block) end diff --git a/app/controllers/boards/issues_controller.rb b/app/controllers/boards/issues_controller.rb index 1298b33471bb775861112bc7a35dc5c5b2544b7f..1d6711e3c226145dd061927413e0d1789bc8988e 100644 --- a/app/controllers/boards/issues_controller.rb +++ b/app/controllers/boards/issues_controller.rb @@ -27,17 +27,7 @@ module Boards issues = list_service.execute issues = issues.page(params[:page]).per(params[:per] || 20).without_count Issue.move_nulls_to_end(issues) if Gitlab::Database.read_write? - issues = issues.preload(:milestone, - :assignees, - project: [ - :route, - { - namespace: [:route] - } - ], - labels: [:priorities], - notes: [:award_emoji, :author] - ) + issues = issues.preload(associations_to_preload) render_issues(issues, list_service.metadata) end @@ -74,6 +64,21 @@ module Boards private + def associations_to_preload + [ + :milestone, + :assignees, + project: [ + :route, + { + namespace: [:route] + } + ], + labels: [:priorities], + notes: [:award_emoji, :author] + ] + end + def can_move_issues? head(:forbidden) unless can?(current_user, :admin_issue, board) end @@ -90,7 +95,7 @@ module Boards end def filter_params - params.merge(board_id: params[:board_id], id: params[:list_id]) + params.permit(*Boards::Issues::ListService.valid_params).merge(board_id: params[:board_id], id: params[:list_id]) .reject { |_, value| value.nil? } end @@ -139,3 +144,5 @@ module Boards end end end + +Boards::IssuesController.prepend_if_ee('EE::Boards::IssuesController') diff --git a/app/controllers/clusters/applications_controller.rb b/app/controllers/clusters/applications_controller.rb index be68d0d0a1dbc00889a484d3fa3c732e5bba3343..51ddbe2beb45ecf0218276a593403b9afee719cd 100644 --- a/app/controllers/clusters/applications_controller.rb +++ b/app/controllers/clusters/applications_controller.rb @@ -47,7 +47,7 @@ class Clusters::ApplicationsController < Clusters::BaseController end def cluster_application_params - params.permit(:application, :hostname, :kibana_hostname, :email, :stack) + params.permit(:application, :hostname, :email, :stack, :modsecurity_enabled) end def cluster_application_destroy_params diff --git a/app/controllers/clusters/clusters_controller.rb b/app/controllers/clusters/clusters_controller.rb index f4b74b14c0b7ff712096e4556f57c88305f2bde7..52a5f801bad2cc05b9cecfe45610642495f49ff7 100644 --- a/app/controllers/clusters/clusters_controller.rb +++ b/app/controllers/clusters/clusters_controller.rb @@ -14,7 +14,6 @@ class Clusters::ClustersController < Clusters::BaseController before_action :update_applications_status, only: [:cluster_status] before_action only: [:show] do push_frontend_feature_flag(:enable_cluster_application_elastic_stack) - push_frontend_feature_flag(:enable_cluster_application_crossplane) end helper_method :token_in_session diff --git a/app/controllers/concerns/record_user_last_activity.rb b/app/controllers/concerns/record_user_last_activity.rb index a394ef9a2b82386675aac85eeb5f79fa2b321d64..4013596ba125458e6ee8010f14cb3ac5ddbb3197 100644 --- a/app/controllers/concerns/record_user_last_activity.rb +++ b/app/controllers/concerns/record_user_last_activity.rb @@ -21,7 +21,7 @@ module RecordUserLastActivity return if Gitlab::Database.read_only? if current_user && current_user.last_activity_on != Date.today - Users::ActivityService.new(current_user, "visited #{request.path}").execute + Users::ActivityService.new(current_user).execute end end end diff --git a/app/controllers/concerns/requires_whitelisted_monitoring_client.rb b/app/controllers/concerns/requires_whitelisted_monitoring_client.rb index 2e9905997dbdd02b0e13495acb0eff31498b4f44..c92b1cecaaa5a1d8cacd4c2abfafe3012c7c31a3 100644 --- a/app/controllers/concerns/requires_whitelisted_monitoring_client.rb +++ b/app/controllers/concerns/requires_whitelisted_monitoring_client.rb @@ -18,7 +18,7 @@ module RequiresWhitelistedMonitoringClient # debugging purposes return true if Rails.env.development? && request.local? - ip_whitelist.any? { |e| e.include?(Gitlab::RequestContext.client_ip) } + ip_whitelist.any? { |e| e.include?(Gitlab::RequestContext.instance.client_ip) } end def ip_whitelist diff --git a/app/controllers/concerns/sourcegraph_gon.rb b/app/controllers/concerns/sourcegraph_decorator.rb similarity index 63% rename from app/controllers/concerns/sourcegraph_gon.rb rename to app/controllers/concerns/sourcegraph_decorator.rb index 01925cf9d4d60450f9c9adad468328f0b0a85467..5ef09b9221fcfd5928343d1dac504b5992d70057 100644 --- a/app/controllers/concerns/sourcegraph_gon.rb +++ b/app/controllers/concerns/sourcegraph_decorator.rb @@ -1,10 +1,19 @@ # frozen_string_literal: true -module SourcegraphGon +module SourcegraphDecorator extend ActiveSupport::Concern included do before_action :push_sourcegraph_gon, if: :html_request? + + content_security_policy do |p| + next if p.directives.blank? + next unless Gitlab::CurrentSettings.sourcegraph_enabled + + default_connect_src = p.directives['connect-src'] || p.directives['default-src'] + connect_src_values = Array.wrap(default_connect_src) | [Gitlab::CurrentSettings.sourcegraph_url] + p.connect_src(*connect_src_values) + end end private diff --git a/app/controllers/concerns/spammable_actions.rb b/app/controllers/concerns/spammable_actions.rb index a8ffa33f1c71fa0f09db673c2570a96caaf1a3dc..9ec8f930a78cef9aef206813718665585730fdb2 100644 --- a/app/controllers/concerns/spammable_actions.rb +++ b/app/controllers/concerns/spammable_actions.rb @@ -11,7 +11,7 @@ module SpammableActions end def mark_as_spam - if SpamService.new(spammable).mark_as_spam! + if Spam::MarkAsSpamService.new(spammable: spammable).execute redirect_to spammable_path, notice: _("%{spammable_titlecase} was submitted to Akismet successfully.") % { spammable_titlecase: spammable.spammable_entity_type.titlecase } else redirect_to spammable_path, alert: _('Error with Akismet. Please check the logs for more info.') diff --git a/app/controllers/graphql_controller.rb b/app/controllers/graphql_controller.rb index 72d40f709e687579d087308e68b6a31b101340fd..d7ff2ded5aec0f697894285301e570cb80559c7b 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! + skip_around_action :set_session_storage # Allow missing CSRF tokens, this would mean that if a CSRF is invalid or missing, # the user won't be authenticated but can proceed as an anonymous user. diff --git a/app/controllers/groups/group_links_controller.rb b/app/controllers/groups/group_links_controller.rb index 7965311c5f1d7a66912bfe86242b6774db4fced6..d3360acd245f31ce3b5b091b9fc73567e6d7217d 100644 --- a/app/controllers/groups/group_links_controller.rb +++ b/app/controllers/groups/group_links_controller.rb @@ -3,6 +3,7 @@ class Groups::GroupLinksController < Groups::ApplicationController before_action :check_feature_flag! before_action :authorize_admin_group! + before_action :group_link, only: [:update, :destroy] def create shared_with_group = Group.find(params[:shared_with_group_id]) if params[:shared_with_group_id].present? @@ -22,13 +23,36 @@ class Groups::GroupLinksController < Groups::ApplicationController redirect_to group_group_members_path(group) end + def update + @group_link.update(group_link_params) + end + + def destroy + Groups::GroupLinks::DestroyService.new(nil, nil).execute(@group_link) + + respond_to do |format| + format.html do + redirect_to group_group_members_path(group), status: :found + end + format.js { head :ok } + end + end + private + def group_link + @group_link ||= group.shared_with_group_links.find(params[:id]) + end + def group_link_create_params params.permit(:shared_group_access, :expires_at) end + def group_link_params + params.require(:group_link).permit(:group_access, :expires_at) + end + def check_feature_flag! - render_404 unless Feature.enabled?(:share_group_with_group) + render_404 unless Feature.enabled?(:share_group_with_group, default_enabled: true) end end diff --git a/app/controllers/groups/group_members_controller.rb b/app/controllers/groups/group_members_controller.rb index dcdf9aced1ac8ad5df908da66e8cc293bfbd90f9..d1eed85fde6532b941e0d9999808a9cadd6a4a0f 100644 --- a/app/controllers/groups/group_members_controller.rb +++ b/app/controllers/groups/group_members_controller.rb @@ -20,28 +20,17 @@ class Groups::GroupMembersController < Groups::ApplicationController :override def index - can_manage_members = can?(current_user, :admin_group_member, @group) - @sort = params[:sort].presence || sort_value_name @project = @group.projects.find(params[:project_id]) if params[:project_id] @members = find_members if can_manage_members - @invited_members = @members.invite - @invited_members = @invited_members.search_invite_email(params[:search_invited]) if params[:search_invited].present? - @invited_members = present_members(@invited_members.page(params[:invited_members_page]).per(MEMBER_PER_PAGE_LIMIT)) + @skip_groups = @group.related_group_ids + @invited_members = present_invited_members(@members) end @members = @members.non_invite - @members = @members.search(params[:search]) if params[:search].present? - @members = @members.sort_by_attribute(@sort) - - if can_manage_members && params[:two_factor].present? - @members = @members.filter_by_2fa(params[:two_factor]) - end - - @members = @members.page(params[:page]).per(MEMBER_PER_PAGE_LIMIT) - @members = present_members(@members) + @members = present_group_members(@members) @requesters = present_members( AccessRequestsFinder.new(@group).execute(current_user)) @@ -54,8 +43,30 @@ class Groups::GroupMembersController < Groups::ApplicationController private + def present_invited_members(members) + invited_members = members.invite + + if params[:search_invited].present? + invited_members = invited_members.search_invite_email(params[:search_invited]) + end + + present_members(invited_members + .page(params[:invited_members_page]) + .per(MEMBER_PER_PAGE_LIMIT)) + end + def find_members - GroupMembersFinder.new(@group).execute(include_relations: requested_relations) + filter_params = params.slice(:two_factor, :search).merge(sort: @sort) + GroupMembersFinder.new(@group, current_user).execute(include_relations: requested_relations, params: filter_params) + end + + def can_manage_members + can?(current_user, :admin_group_member, @group) + end + + def present_group_members(original_members) + members = original_members.page(params[:page]).per(MEMBER_PER_PAGE_LIMIT) + present_members(members) end end diff --git a/app/controllers/groups/milestones_controller.rb b/app/controllers/groups/milestones_controller.rb index 1e9d51cf970294113f05d73d2b2594dd0d3dd6d5..7eba73daa3cc18e081e860e11ea36eb3f23d87fb 100644 --- a/app/controllers/groups/milestones_controller.rb +++ b/app/controllers/groups/milestones_controller.rb @@ -119,7 +119,9 @@ class Groups::MilestonesController < Groups::ApplicationController end def search_params - params.permit(:state, :search_title).merge(group_ids: group.id) + groups = request.format.json? ? group.self_and_ancestors.select(:id) : group.id + + params.permit(:state, :search_title).merge(group_ids: groups) end end diff --git a/app/controllers/ide_controller.rb b/app/controllers/ide_controller.rb index 4c9aac9a3277db0d5523d2339a1ee8b41b2fd297..ca35b07111cc4b423522f71eb3cb34db684d6a0a 100644 --- a/app/controllers/ide_controller.rb +++ b/app/controllers/ide_controller.rb @@ -3,6 +3,10 @@ class IdeController < ApplicationController layout 'fullscreen' + before_action do + push_frontend_feature_flag(:stage_all_by_default, default_enabled: true) + end + def index Gitlab::UsageDataCounters::WebIdeCounter.increment_views_count end diff --git a/app/controllers/omniauth_callbacks_controller.rb b/app/controllers/omniauth_callbacks_controller.rb index 92f36c031f15c47a81cc9b1877200b520375df2c..bc3308fd6c6871e3eca8910faa937ca7be6b1a1b 100644 --- a/app/controllers/omniauth_callbacks_controller.rb +++ b/app/controllers/omniauth_callbacks_controller.rb @@ -31,7 +31,7 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController # Extend the standard message generation to accept our custom exception def failure_message exception = request.env["omniauth.error"] - error = exception.error_reason if exception.respond_to?(:error_reason) + error = exception.error_reason if exception.respond_to?(:error_reason) error ||= exception.error if exception.respond_to?(:error) error ||= exception.message if exception.respond_to?(:message) error ||= request.env["omniauth.error.type"].to_s @@ -177,7 +177,7 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController message << _("Create a GitLab account first, and then connect it to your %{label} account.") % { label: label } end - flash[:notice] = message.join(' ') + flash[:alert] = message.join(' ') redirect_to new_user_session_path end diff --git a/app/controllers/profiles/active_sessions_controller.rb b/app/controllers/profiles/active_sessions_controller.rb index c473023cacb588a4d46aa8f23775e95a90aaca67..f409193aefcb14983896ecc6baa829403630216b 100644 --- a/app/controllers/profiles/active_sessions_controller.rb +++ b/app/controllers/profiles/active_sessions_controller.rb @@ -4,4 +4,13 @@ class Profiles::ActiveSessionsController < Profiles::ApplicationController def index @sessions = ActiveSession.list(current_user).reject(&:is_impersonated) end + + def destroy + ActiveSession.destroy_with_public_id(current_user, params[:id]) + + respond_to do |format| + format.html { redirect_to profile_active_sessions_url, status: :found } + format.js { head :ok } + end + end end diff --git a/app/controllers/profiles/preferences_controller.rb b/app/controllers/profiles/preferences_controller.rb index 214640a5295f782e932f8629aab83c07411c0c86..2166dd7dad76d8e2bfa919fee15d726105359403 100644 --- a/app/controllers/profiles/preferences_controller.rb +++ b/app/controllers/profiles/preferences_controller.rb @@ -48,7 +48,8 @@ class Profiles::PreferencesController < Profiles::ApplicationController :time_display_relative, :time_format_in_24h, :show_whitespace_in_diffs, - :sourcegraph_enabled + :sourcegraph_enabled, + :render_whitespace_in_code ] end end diff --git a/app/controllers/projects/blame_controller.rb b/app/controllers/projects/blame_controller.rb index 92655d593dde7e29ec28ba055f632ecef077cf58..b62ce940e9c6d81015a82bfc12d0e12418274a95 100644 --- a/app/controllers/projects/blame_controller.rb +++ b/app/controllers/projects/blame_controller.rb @@ -17,6 +17,7 @@ class Projects::BlameController < Projects::ApplicationController end environment_params = @repository.branch_exists?(@ref) ? { ref: @ref } : { commit: @commit } + environment_params[:find_latest] = true @environment = EnvironmentsFinder.new(@project, current_user, environment_params).execute.last @blame_groups = Gitlab::Blame.new(@blob, @commit).groups diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb index 7c97f771a70e0d96969bb53e1328fec0ce7c04b8..3cd14cf845fe8670720a4a372c2c45e48567e034 100644 --- a/app/controllers/projects/blob_controller.rb +++ b/app/controllers/projects/blob_controller.rb @@ -8,7 +8,7 @@ class Projects::BlobController < Projects::ApplicationController include NotesHelper include ActionView::Helpers::SanitizeHelper include RedirectsForMissingPathOnTree - include SourcegraphGon + include SourcegraphDecorator prepend_before_action :authenticate_user!, only: [:edit] @@ -205,6 +205,7 @@ class Projects::BlobController < Projects::ApplicationController def show_html environment_params = @repository.branch_exists?(@ref) ? { ref: @ref } : { commit: @commit } + environment_params[:find_latest] = true @environment = EnvironmentsFinder.new(@project, current_user, environment_params).execute.last @last_commit = @repository.last_commit_for_path(@commit.id, @blob.path) diff --git a/app/controllers/projects/ci/lints_controller.rb b/app/controllers/projects/ci/lints_controller.rb index 812420e970876249d920fc64c74e106e8a1cd346..b50afa12da051c38ca2b1677b48fa09686ab67c6 100644 --- a/app/controllers/projects/ci/lints_controller.rb +++ b/app/controllers/projects/ci/lints_controller.rb @@ -10,8 +10,8 @@ class Projects::Ci::LintsController < Projects::ApplicationController @content = params[:content] result = Gitlab::Ci::YamlProcessor.new_with_validation_errors(@content, yaml_processor_options) - @error = result.errors.join(', ') - @status = result.valid? + @status = result.valid? + @errors = result.errors if result.valid? @config_processor = result.content diff --git a/app/controllers/projects/commit_controller.rb b/app/controllers/projects/commit_controller.rb index afb670b687bc00d3f4a9ff803482e8372bb1facd..3f2dc9b09fa14512d759571c7813f64b90ab13eb 100644 --- a/app/controllers/projects/commit_controller.rb +++ b/app/controllers/projects/commit_controller.rb @@ -8,7 +8,7 @@ class Projects::CommitController < Projects::ApplicationController include CreatesCommit include DiffForPath include DiffHelper - include SourcegraphGon + include SourcegraphDecorator # Authorize before_action :require_non_empty_project @@ -151,7 +151,7 @@ class Projects::CommitController < Projects::ApplicationController @diffs = commit.diffs(opts) @notes_count = commit.notes.count - @environment = EnvironmentsFinder.new(@project, current_user, commit: @commit).execute.last + @environment = EnvironmentsFinder.new(@project, current_user, commit: @commit, find_latest: true).execute.last end # rubocop: disable CodeReuse/ActiveRecord diff --git a/app/controllers/projects/compare_controller.rb b/app/controllers/projects/compare_controller.rb index 5586c2fc63162c5d446e4e7a5b0d1244fcfdbff9..943277afe95438d38c3fa07c983999fcf7b40bf1 100644 --- a/app/controllers/projects/compare_controller.rb +++ b/app/controllers/projects/compare_controller.rb @@ -101,6 +101,7 @@ class Projects::CompareController < Projects::ApplicationController def define_environment if compare environment_params = @repository.branch_exists?(head_ref) ? { ref: head_ref } : { commit: compare.commit } + environment_params[:find_latest] = true @environment = EnvironmentsFinder.new(@project, current_user, environment_params).execute.last end end diff --git a/app/controllers/projects/environments/sample_metrics_controller.rb b/app/controllers/projects/environments/sample_metrics_controller.rb index 79a7eab150bcbc800ff9c9ef962ba81131d2ccbf..9176c7cbd5656d3820149762ea499d5b9ea3e253 100644 --- a/app/controllers/projects/environments/sample_metrics_controller.rb +++ b/app/controllers/projects/environments/sample_metrics_controller.rb @@ -2,7 +2,7 @@ class Projects::Environments::SampleMetricsController < Projects::ApplicationController def query - result = Metrics::SampleMetricsService.new(params[:identifier]).query + result = Metrics::SampleMetricsService.new(params[:identifier], range_start: params[:start], range_end: params[:end]).query if result render json: { "status": "success", "data": { "resultType": "matrix", "result": result } } diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb index 1179782036d724fb5f1c207b487b767650010252..70c4b53685411f7685d23ba97d2c9b5480bdf063 100644 --- a/app/controllers/projects/environments_controller.rb +++ b/app/controllers/projects/environments_controller.rb @@ -15,11 +15,15 @@ class Projects::EnvironmentsController < Projects::ApplicationController before_action only: [:metrics, :additional_metrics, :metrics_dashboard] do push_frontend_feature_flag(:prometheus_computed_alerts) end + before_action do + push_frontend_feature_flag(:auto_stop_environments) + end after_action :expire_etag_cache, only: [:cancel_auto_stop] def index @environments = project.environments .with_state(params[:scope] || :available) + @project = ProjectPresenter.new(project, current_user: current_user) respond_to do |format| format.html @@ -28,6 +32,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController render json: { environments: serialize_environments(request, response, params[:nested]), + review_app: serialize_review_app, available_count: project.environments.available.count, stopped_count: project.environments.stopped.count } @@ -217,7 +222,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController def metrics_dashboard_params params - .permit(:embedded, :group, :title, :y_label, :dashboard_path, :environment) + .permit(:embedded, :group, :title, :y_label, :dashboard_path, :environment, :sample_metrics) .merge(dashboard_path: params[:dashboard], environment: environment) end @@ -239,6 +244,10 @@ class Projects::EnvironmentsController < Projects::ApplicationController .represent(@environments) end + def serialize_review_app + ReviewAppSetupSerializer.new(current_user: @current_user).represent(@project) + end + def authorize_stop_environment! access_denied! unless can?(current_user, :stop_environment, environment) end diff --git a/app/controllers/projects/error_tracking/base_controller.rb b/app/controllers/projects/error_tracking/base_controller.rb new file mode 100644 index 0000000000000000000000000000000000000000..6efc6d00702ad843a59cb2e34a57ffbfe7d45cf8 --- /dev/null +++ b/app/controllers/projects/error_tracking/base_controller.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class Projects::ErrorTracking::BaseController < Projects::ApplicationController + POLLING_INTERVAL = 1_000 + + def set_polling_interval + Gitlab::PollingInterval.set_header(response, interval: POLLING_INTERVAL) + end +end diff --git a/app/controllers/projects/error_tracking/projects_controller.rb b/app/controllers/projects/error_tracking/projects_controller.rb new file mode 100644 index 0000000000000000000000000000000000000000..75a2c976d8bd9d107cce300468a1b8bdc0132c1c --- /dev/null +++ b/app/controllers/projects/error_tracking/projects_controller.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Projects + module ErrorTracking + class ProjectsController < Projects::ApplicationController + respond_to :json + + before_action :authorize_read_sentry_issue! + + def index + service = ::ErrorTracking::ListProjectsService.new( + project, + current_user, + list_projects_params + ) + result = service.execute + + if result[:status] == :success + render json: { projects: serialize_projects(result[:projects]) } + else + render( + status: result[:http_status] || :bad_request, + json: { message: result[:message] } + ) + end + end + + private + + def list_projects_params + { api_host: params[:api_host], token: params[:token] } + end + + def serialize_projects(projects) + ::ErrorTracking::ProjectSerializer + .new(project: project, user: current_user) + .represent(projects) + end + end + end +end diff --git a/app/controllers/projects/error_tracking/stack_traces_controller.rb b/app/controllers/projects/error_tracking/stack_traces_controller.rb new file mode 100644 index 0000000000000000000000000000000000000000..c5d5d6da6a6a94a343fc369bf2ecb763de5ecd97 --- /dev/null +++ b/app/controllers/projects/error_tracking/stack_traces_controller.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Projects + module ErrorTracking + class StackTracesController < Projects::ErrorTracking::BaseController + respond_to :json + + before_action :authorize_read_sentry_issue!, :set_polling_interval + + def index + result = fetch_latest_event_issue + + if result[:status] == :success + result_with_syntax_highlight = Gitlab::ErrorTracking::StackTraceHighlightDecorator.decorate(result[:latest_event]) + + render json: { error: serialize_error_event(result_with_syntax_highlight) } + else + render json: { message: result[:message] }, status: result.fetch(:http_status, :bad_request) + end + end + + private + + def fetch_latest_event_issue + ::ErrorTracking::IssueLatestEventService + .new(project, current_user, issue_id: params[:issue_id]) + .execute + end + + def serialize_error_event(event) + ::ErrorTracking::ErrorEventSerializer + .new(project: project, user: current_user) + .represent(event) + end + end + end +end diff --git a/app/controllers/projects/error_tracking_controller.rb b/app/controllers/projects/error_tracking_controller.rb index ba21ccfb169eeb5ab4e747d43ea358e1d7876759..88f739ce29ed386f59a2bcdc0a49eff03ed9f878 100644 --- a/app/controllers/projects/error_tracking_controller.rb +++ b/app/controllers/projects/error_tracking_controller.rb @@ -1,10 +1,10 @@ # frozen_string_literal: true -class Projects::ErrorTrackingController < Projects::ApplicationController - before_action :authorize_read_sentry_issue! - before_action :set_issue_id, only: [:details, :stack_trace] +class Projects::ErrorTrackingController < Projects::ErrorTracking::BaseController + respond_to :json - POLLING_INTERVAL = 10_000 + before_action :authorize_read_sentry_issue! + before_action :set_issue_id, only: :details def index respond_to do |format| @@ -20,25 +20,21 @@ class Projects::ErrorTrackingController < Projects::ApplicationController respond_to do |format| format.html format.json do + set_polling_interval render_issue_detail_json end end end - def stack_trace - respond_to do |format| - format.json do - render_issue_stack_trace_json - end - end - end + def update + service = ErrorTracking::IssueUpdateService.new(project, current_user, issue_update_params) + result = service.execute - def list_projects - respond_to do |format| - format.json do - render_project_list_json - end - end + return if handle_errors(result) + + render json: { + result: result + } end private @@ -71,41 +67,6 @@ class Projects::ErrorTrackingController < Projects::ApplicationController } end - def render_issue_stack_trace_json - service = ErrorTracking::IssueLatestEventService.new(project, current_user, issue_details_params) - result = service.execute - - return if handle_errors(result) - - result_with_syntax_highlight = Gitlab::ErrorTracking::StackTraceHighlightDecorator.decorate(result[:latest_event]) - - render json: { - error: serialize_error_event(result_with_syntax_highlight) - } - end - - def render_project_list_json - service = ErrorTracking::ListProjectsService.new( - project, - current_user, - list_projects_params - ) - result = service.execute - - if result[:status] == :success - render json: { - projects: serialize_projects(result[:projects]) - } - else - return render( - status: result[:http_status] || :bad_request, - json: { - message: result[:message] - } - ) - end - end - def handle_errors(result) unless result[:status] == :success render json: { message: result[:message] }, @@ -117,8 +78,8 @@ class Projects::ErrorTrackingController < Projects::ApplicationController params.permit(:search_term, :sort, :cursor) end - def list_projects_params - params.require(:error_tracking_setting).permit([:api_host, :token]) + def issue_update_params + params.permit(:issue_id, :status) end def issue_details_params @@ -129,10 +90,6 @@ class Projects::ErrorTrackingController < Projects::ApplicationController @issue_id = issue_details_params[:issue_id] end - def set_polling_interval - Gitlab::PollingInterval.set_header(response, interval: POLLING_INTERVAL) - end - def serialize_errors(errors) ErrorTracking::ErrorSerializer .new(project: project, user: current_user) @@ -144,16 +101,4 @@ class Projects::ErrorTrackingController < Projects::ApplicationController .new(project: project, user: current_user) .represent(error) end - - def serialize_error_event(event) - ErrorTracking::ErrorEventSerializer - .new(project: project, user: current_user) - .represent(event) - end - - def serialize_projects(projects) - ErrorTracking::ProjectSerializer - .new(project: project, user: current_user) - .represent(projects) - end end diff --git a/app/controllers/projects/forks_controller.rb b/app/controllers/projects/forks_controller.rb index cb6d9c2ba18b96c2aad92efe48305787db2a22cb..9806b91c7e8f8a8f221a034aa92336933bcd4f03 100644 --- a/app/controllers/projects/forks_controller.rb +++ b/app/controllers/projects/forks_controller.rb @@ -9,6 +9,7 @@ class Projects::ForksController < Projects::ApplicationController before_action :require_non_empty_project before_action :authorize_download_code! before_action :authenticate_user!, only: [:new, :create] + before_action :authorize_fork_project!, only: [:new, :create] # rubocop: disable CodeReuse/ActiveRecord def index @@ -61,6 +62,8 @@ class Projects::ForksController < Projects::ApplicationController end # rubocop: enable CodeReuse/ActiveRecord + private + def whitelist_query_limiting Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-foss/issues/42335') end diff --git a/app/controllers/projects/git_http_client_controller.rb b/app/controllers/projects/git_http_client_controller.rb index ccfc38d97b2d0eac88a8b7d418d4fbe45b272e27..3f6e116a62b819cd6d8726d713d569e19602d6b5 100644 --- a/app/controllers/projects/git_http_client_controller.rb +++ b/app/controllers/projects/git_http_client_controller.rb @@ -3,6 +3,7 @@ class Projects::GitHttpClientController < Projects::ApplicationController include ActionController::HttpAuthentication::Basic include KerberosSpnegoHelper + include Gitlab::Utils::StrongMemoize attr_reader :authentication_result, :redirected_path @@ -47,7 +48,7 @@ class Projects::GitHttpClientController < Projects::ApplicationController send_final_spnego_response return # Allow access end - elsif project && download_request? && http_allowed? && Guest.can?(:download_code, project) + elsif http_download_allowed? @authentication_result = Gitlab::Auth::Result.new(nil, project, :none, [:download_code]) @@ -89,11 +90,9 @@ class Projects::GitHttpClientController < Projects::ApplicationController end def repository - repo_type.repository_for(project) - end - - def wiki? - repo_type.wiki? + strong_memoize(:repository) do + repo_type.repository_for(project) + end end def repo_type @@ -113,8 +112,10 @@ class Projects::GitHttpClientController < Projects::ApplicationController authentication_result.ci?(project) end - def http_allowed? - Gitlab::ProtocolAccess.allowed?('http') + def http_download_allowed? + Gitlab::ProtocolAccess.allowed?('http') && + download_request? && + project && Guest.can?(:download_code, project) end end diff --git a/app/controllers/projects/git_http_controller.rb b/app/controllers/projects/git_http_controller.rb index 93f7ce73a51475f24ed0bfa1499b754393141472..236f1b967de7e5090d6a63f61d3b88a613478daa 100644 --- a/app/controllers/projects/git_http_controller.rb +++ b/app/controllers/projects/git_http_controller.rb @@ -75,17 +75,20 @@ class Projects::GitHttpController < Projects::GitHttpClientController end def enqueue_fetch_statistics_update - return if wiki? - return unless project.daily_statistics_enabled? + return if Gitlab::Database.read_only? + return if repo_type.wiki? + return unless project&.daily_statistics_enabled? ProjectDailyStatisticsWorker.perform_async(project.id) end def access - @access ||= access_klass.new(access_actor, project, - 'http', authentication_abilities: authentication_abilities, - namespace_path: params[:namespace_id], project_path: project_path, - redirected_path: redirected_path, auth_result_type: auth_result_type) + @access ||= access_klass.new(access_actor, project, 'http', + authentication_abilities: authentication_abilities, + namespace_path: params[:namespace_id], + project_path: project_path, + redirected_path: redirected_path, + auth_result_type: auth_result_type) end def access_actor @@ -107,7 +110,7 @@ class Projects::GitHttpController < Projects::GitHttpClientController end def log_user_activity - Users::ActivityService.new(user, 'pull').execute + Users::ActivityService.new(user).execute end end diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index 229374c39290174590a627e8e505a1fb0a13c465..0944d7b47bfa047a5d93643b987534a913cd6e2e 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -44,9 +44,11 @@ class Projects::IssuesController < Projects::ApplicationController before_action do push_frontend_feature_flag(:vue_issuable_sidebar, project.group) - push_frontend_feature_flag(:release_search_filter, project, default_enabled: true) + push_frontend_feature_flag(:issue_link_types, project) end + around_action :allow_gitaly_ref_name_caching, only: [:discussions] + respond_to :html alias_method :designs, :show diff --git a/app/controllers/projects/jobs_controller.rb b/app/controllers/projects/jobs_controller.rb index 796f3ff603f72c4b17607d725709c34500309110..cb473d6ee96c9766d2ed090d57b867d0d917ace9 100644 --- a/app/controllers/projects/jobs_controller.rb +++ b/app/controllers/projects/jobs_controller.rb @@ -51,6 +51,8 @@ class Projects::JobsController < Projects::ApplicationController build.trace.read do |stream| respond_to do |format| format.json do + build.trace.being_watched! + # TODO: when the feature flag is removed we should not pass # content_format to serialize method. content_format = Feature.enabled?(:job_log_json, @project, default_enabled: true) ? :json : :html diff --git a/app/controllers/projects/merge_requests/creations_controller.rb b/app/controllers/projects/merge_requests/creations_controller.rb index 78dc196b08e2d642872ec1737618f4806a6bdd79..23222cbd37ce8203e7d5685bf7182f0b2b37bb6b 100644 --- a/app/controllers/projects/merge_requests/creations_controller.rb +++ b/app/controllers/projects/merge_requests/creations_controller.rb @@ -12,10 +12,7 @@ class Projects::MergeRequests::CreationsController < Projects::MergeRequests::Ap before_action :build_merge_request, except: [:create] def new - # n+1: https://gitlab.com/gitlab-org/gitlab-foss/issues/40934 - Gitlab::GitalyClient.allow_n_plus_1_calls do - define_new_vars - end + define_new_vars end def create @@ -52,7 +49,7 @@ class Projects::MergeRequests::CreationsController < Projects::MergeRequests::Ap @diff_notes_disabled = true - @environment = @merge_request.environments_for(current_user).last + @environment = @merge_request.environments_for(current_user, latest: true).last render json: { html: view_to_html_string('projects/merge_requests/creations/_diffs', diffs: @diffs, environment: @environment) } end diff --git a/app/controllers/projects/merge_requests/diffs_controller.rb b/app/controllers/projects/merge_requests/diffs_controller.rb index 37d90ecdc008a1592ca7858ff0545729c350137e..c0c8474232a7811708c0a747f62d21bcdb4aa6f7 100644 --- a/app/controllers/projects/merge_requests/diffs_controller.rb +++ b/app/controllers/projects/merge_requests/diffs_controller.rb @@ -51,7 +51,7 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic # Deprecated: https://gitlab.com/gitlab-org/gitlab/issues/37735 def render_diffs diffs = @compare.diffs(diff_options) - @environment = @merge_request.environments_for(current_user).last + @environment = @merge_request.environments_for(current_user, latest: true).last diffs.unfold_diff_files(note_positions.unfoldable) diffs.write_cache diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index 69e3e7c7acbe3ba9d4072513d583ad64e4119557..170256704880ca63d9b59f463d23873635b3924c 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -9,7 +9,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo include ToggleAwardEmoji include IssuableCollections include RecordUserLastActivity - include SourcegraphGon + include SourcegraphDecorator skip_before_action :merge_request, only: [:index, :bulk_update] before_action :whitelist_query_limiting, only: [:assign_related_issues, :update] @@ -25,7 +25,6 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo before_action do push_frontend_feature_flag(:vue_issuable_sidebar, @project.group) - push_frontend_feature_flag(:release_search_filter, @project, default_enabled: true) push_frontend_feature_flag(:async_mr_widget, @project) end @@ -222,11 +221,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo def ci_environments_status environments = if ci_environments_status_on_merge_result? - if Feature.enabled?(:deployment_merge_requests_widget, @project) - EnvironmentStatus.for_deployed_merge_request(@merge_request, current_user) - else - EnvironmentStatus.after_merge_request(@merge_request, current_user) - end + EnvironmentStatus.for_deployed_merge_request(@merge_request, current_user) else EnvironmentStatus.for_merge_request(@merge_request, current_user) end diff --git a/app/controllers/projects/pages_controller.rb b/app/controllers/projects/pages_controller.rb index f1e591ea1ecc6e0789c61028ca98aae76411de72..18a171700e9636c61de825a1f7e1c27ccc66bb86 100644 --- a/app/controllers/projects/pages_controller.rb +++ b/app/controllers/projects/pages_controller.rb @@ -34,7 +34,7 @@ class Projects::PagesController < Projects::ApplicationController if result[:status] == :success flash[:notice] = 'Your changes have been saved' else - flash[:alert] = 'Something went wrong on our end' + flash[:alert] = result[:message] end redirect_to project_pages_path(@project) @@ -45,6 +45,12 @@ class Projects::PagesController < Projects::ApplicationController private def project_params - params.require(:project).permit(:pages_https_only) + params.require(:project).permit(project_params_attributes) + end + + def project_params_attributes + %i[pages_https_only] end end + +Projects::PagesController.prepend_if_ee('EE::Projects::PagesController') diff --git a/app/controllers/projects/performance_monitoring/dashboards_controller.rb b/app/controllers/projects/performance_monitoring/dashboards_controller.rb new file mode 100644 index 0000000000000000000000000000000000000000..2d872b780961637ffd6b0567d426867bdff7147b --- /dev/null +++ b/app/controllers/projects/performance_monitoring/dashboards_controller.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module Projects + module PerformanceMonitoring + class DashboardsController < ::Projects::ApplicationController + include BlobHelper + + before_action :check_repository_available! + before_action :validate_required_params! + + rescue_from ActionController::ParameterMissing do |exception| + respond_error(http_status: :bad_request, message: _('Request parameter %{param} is missing.') % { param: exception.param }) + end + + def create + result = ::Metrics::Dashboard::CloneDashboardService.new(project, current_user, dashboard_params).execute + + if result[:status] == :success + respond_success(result) + else + respond_error(result) + end + end + + private + + def respond_success(result) + set_web_ide_link_notice(result.dig(:dashboard, :path)) + respond_to do |format| + format.json { render status: result.delete(:http_status), json: result } + end + end + + def respond_error(result) + respond_to do |format| + format.json { render json: { error: result[:message] }, status: result[:http_status] } + end + end + + def set_web_ide_link_notice(new_dashboard_path) + web_ide_link_start = "<a href=\"#{ide_edit_path(project, redirect_safe_branch_name, new_dashboard_path)}\">" + message = _("Your dashboard has been copied. You can %{web_ide_link_start}edit it here%{web_ide_link_end}.") % { web_ide_link_start: web_ide_link_start, web_ide_link_end: "</a>" } + flash[:notice] = message.html_safe + end + + def validate_required_params! + params.require(%i(branch file_name dashboard commit_message)) + end + + def redirect_safe_branch_name + repository.find_branch(params[:branch]).name + end + + def dashboard_params + params.permit(%i(branch file_name dashboard commit_message)).to_h + end + end + end +end diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb index e3ef8f3f2ffbf668e18b34c20d36c34ce18e6965..a62eb94a3e4249e6ac062184ddbf901fc8ffad60 100644 --- a/app/controllers/projects/pipelines_controller.rb +++ b/app/controllers/projects/pipelines_controller.rb @@ -80,6 +80,12 @@ class Projects::PipelinesController < Projects::ApplicationController end end + def destroy + ::Ci::DestroyPipelineService.new(project, current_user).execute(pipeline) + + redirect_to project_pipelines_path(project), status: :see_other + end + def builds render_show end diff --git a/app/controllers/projects/prometheus/metrics_controller.rb b/app/controllers/projects/prometheus/metrics_controller.rb index 267dca74f96931e12020a0ee8b4224723ddd564e..c9c7ba1253fbb7de058b92fb0d42f9c375ddf549 100644 --- a/app/controllers/projects/prometheus/metrics_controller.rb +++ b/app/controllers/projects/prometheus/metrics_controller.rb @@ -23,7 +23,7 @@ module Projects private def prometheus_adapter - @prometheus_adapter ||= ::Prometheus::AdapterService.new(project).prometheus_adapter + @prometheus_adapter ||= ::Gitlab::Prometheus::Adapter.new(project, project.deployment_platform&.cluster).prometheus_adapter end def require_prometheus_metrics! diff --git a/app/controllers/projects/raw_controller.rb b/app/controllers/projects/raw_controller.rb index f39d98be516bc864bb4100e9ad29a7b428e06ce1..f7bc6898112ab8eed8e00d7e118b9a0a30389297 100644 --- a/app/controllers/projects/raw_controller.rb +++ b/app/controllers/projects/raw_controller.rb @@ -9,9 +9,9 @@ class Projects::RawController < Projects::ApplicationController prepend_before_action(only: [:show]) { authenticate_sessionless_user!(:blob) } before_action :require_non_empty_project - before_action :assign_ref_vars before_action :authorize_download_code! before_action :show_rate_limit, only: [:show], unless: :external_storage_request? + before_action :assign_ref_vars before_action :redirect_to_external_storage, only: :show, if: :static_objects_external_storage_enabled? def show @@ -23,11 +23,15 @@ class Projects::RawController < Projects::ApplicationController private def show_rate_limit - if rate_limiter.throttled?(:show_raw_controller, scope: [@project, @commit, @path], threshold: raw_blob_request_limit) + # This bypasses assign_ref_vars to avoid a Gitaly FindCommit lookup. + # When rate limiting, we really don't care if a different commit is + # being requested. + _ref, path = extract_ref(get_id) + + if rate_limiter.throttled?(:show_raw_controller, scope: [@project, path], threshold: raw_blob_request_limit) rate_limiter.log_request(request, :raw_blob_request_limit, current_user) - flash[:alert] = _('You cannot access the raw file. Please wait a minute.') - redirect_to project_blob_path(@project, File.join(@ref, @path)), status: :too_many_requests + render plain: _('You cannot access the raw file. Please wait a minute.'), status: :too_many_requests end end diff --git a/app/controllers/projects/releases_controller.rb b/app/controllers/projects/releases_controller.rb index d6030a9e455d955a2ba00d2102d8d632cdf470d7..08a57a9b146c46e5a3eb411d54d8bb8a472de342 100644 --- a/app/controllers/projects/releases_controller.rb +++ b/app/controllers/projects/releases_controller.rb @@ -7,7 +7,7 @@ class Projects::ReleasesController < Projects::ApplicationController before_action :authorize_read_release! before_action do push_frontend_feature_flag(:release_issue_summary, project) - push_frontend_feature_flag(:release_evidence_collection, project) + push_frontend_feature_flag(:release_evidence_collection, project, default_enabled: true) end before_action :authorize_update_release!, only: %i[edit update] before_action :authorize_read_release_evidence!, only: [:evidence] diff --git a/app/controllers/projects/snippets_controller.rb b/app/controllers/projects/snippets_controller.rb index dbd11c8ddc8279b61be102d5727b54bf5978cafd..daddd9dd48555c07ba42beb4b09efb7172f5a8e7 100644 --- a/app/controllers/projects/snippets_controller.rb +++ b/app/controllers/projects/snippets_controller.rb @@ -46,8 +46,8 @@ class Projects::SnippetsController < Projects::ApplicationController def create create_params = snippet_params.merge(spammable_params) - - @snippet = CreateSnippetService.new(@project, current_user, create_params).execute + service_response = Snippets::CreateService.new(project, current_user, create_params).execute + @snippet = service_response.payload[:snippet] recaptcha_check_with_fallback { render :new } end @@ -55,7 +55,8 @@ class Projects::SnippetsController < Projects::ApplicationController def update update_params = snippet_params.merge(spammable_params) - UpdateSnippetService.new(project, current_user, @snippet, update_params).execute + service_response = Snippets::UpdateService.new(project, current_user, update_params).execute(@snippet) + @snippet = service_response.payload[:snippet] recaptcha_check_with_fallback { render :edit } end @@ -89,11 +90,17 @@ class Projects::SnippetsController < Projects::ApplicationController end def destroy - return access_denied! unless can?(current_user, :admin_project_snippet, @snippet) - - @snippet.destroy - - redirect_to project_snippets_path(@project), status: :found + service_response = Snippets::DestroyService.new(current_user, @snippet).execute + + if service_response.success? + redirect_to project_snippets_path(project), status: :found + elsif service_response.http_status == 403 + access_denied! + else + redirect_to project_snippet_path(project, @snippet), + status: :found, + alert: service_response.message + end end protected diff --git a/app/controllers/projects/starrers_controller.rb b/app/controllers/projects/starrers_controller.rb index 4efe956e973852ef8b1bda1bb60c8a3233edd4d2..d9654f4f72ad2bba7718330f86508aaf2115ea12 100644 --- a/app/controllers/projects/starrers_controller.rb +++ b/app/controllers/projects/starrers_controller.rb @@ -7,8 +7,8 @@ class Projects::StarrersController < Projects::ApplicationController @starrers = UsersStarProjectsFinder.new(@project, params, current_user: @current_user).execute @sort = params[:sort].presence || sort_value_name @starrers = @starrers.preload_users.sort_by_attribute(@sort).page(params[:page]) - @public_count = @project.starrers.with_public_profile.size - @total_count = @project.starrers.size + @public_count = @project.starrers.with_public_profile.size + @total_count = @project.starrers.size @private_count = @total_count - @public_count end diff --git a/app/controllers/projects/tree_controller.rb b/app/controllers/projects/tree_controller.rb index eec89afe354a95540f1148311f3a7a63bd08adc5..aba28e5c8354d33e5f7889f99a379a1061402611 100644 --- a/app/controllers/projects/tree_controller.rb +++ b/app/controllers/projects/tree_controller.rb @@ -15,6 +15,10 @@ class Projects::TreeController < Projects::ApplicationController before_action :authorize_download_code! before_action :authorize_edit_tree!, only: [:create_dir] + before_action only: [:show] do + push_frontend_feature_flag(:vue_file_list_lfs_badge) + end + def show return render_404 unless @repository.commit(@ref) @@ -28,7 +32,8 @@ class Projects::TreeController < Projects::ApplicationController respond_to do |format| format.html do - lfs_blob_ids + lfs_blob_ids if Feature.disabled?(:vue_file_list, @project) + @last_commit = @repository.last_commit_for_path(@commit.id, @tree.path) || @commit end end diff --git a/app/controllers/projects/uploads_controller.rb b/app/controllers/projects/uploads_controller.rb index 3e5a1cfc74dea627b92c8cb4f4b914fd9e07d48f..72251988b5e92608d8d23b12df080a9de14fd3d4 100644 --- a/app/controllers/projects/uploads_controller.rb +++ b/app/controllers/projects/uploads_controller.rb @@ -29,4 +29,14 @@ class Projects::UploadsController < Projects::ApplicationController Project.find_by_full_path("#{namespace}/#{id}") end + + # Overrides ApplicationController#build_canonical_path since there are + # multiple routes that match project uploads: + # https://gitlab.com/gitlab-org/gitlab/issues/196396 + def build_canonical_path(project) + return super unless action_name == 'show' + return super unless params[:secret] && params[:filename] + + show_namespace_project_uploads_url(project.namespace.to_param, project.to_param, params[:secret], params[:filename]) + end end diff --git a/app/controllers/projects/wikis_controller.rb b/app/controllers/projects/wikis_controller.rb index fb06299676c37f1c46a9b199954feb17f463a21a..cfc0925d9e175f9c21205d7bb97d857aa744c5e8 100644 --- a/app/controllers/projects/wikis_controller.rb +++ b/app/controllers/projects/wikis_controller.rb @@ -39,6 +39,10 @@ class Projects::WikisController < Projects::ApplicationController if @page set_encoding_error unless valid_encoding? + # Assign vars expected by MarkupHelper + @ref = params[:version_id] + @path = @page.path + render 'show' elsif file_blob send_blob(@project_wiki.repository, file_blob) diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index 47d6fb67108593dbc7dfe75d250c4ac104781fd1..bf05defbc2e6ef79b4ffaa1231abd26380740bf1 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -21,8 +21,7 @@ class ProjectsController < Projects::ApplicationController before_action :assign_ref_vars, if: -> { action_name == 'show' && repo_exists? } before_action :tree, if: -> { action_name == 'show' && repo_exists? && project_view_files? } - before_action :lfs_blob_ids, - if: -> { action_name == 'show' && repo_exists? && project_view_files? } + before_action :lfs_blob_ids, if: :show_blob_ids?, only: :show before_action :project_export_enabled, only: [:export, :download_export, :remove_export, :generate_new_export] before_action :present_project, only: [:edit] before_action :authorize_download_code!, only: [:refs] @@ -52,7 +51,7 @@ class ProjectsController < Projects::ApplicationController def edit @badge_api_endpoint = expose_url(api_v4_projects_badges_path(id: @project.id)) - render 'edit' + render_edit end def create @@ -86,7 +85,7 @@ class ProjectsController < Projects::ApplicationController else flash.now[:alert] = result[:message] - format.html { render 'edit' } + format.html { render_edit } end format.js @@ -296,6 +295,10 @@ class ProjectsController < Projects::ApplicationController private + def show_blob_ids? + repo_exists? && project_view_files? && Feature.disabled?(:vue_file_list, @project) + end + # Render project landing depending of which features are available # So if page is not available in the list it renders the next page # @@ -383,10 +386,12 @@ class ProjectsController < Projects::ApplicationController :template_project_id, :merge_method, :initialize_with_readme, + :autoclose_referenced_issues, project_feature_attributes: %i[ builds_access_level issues_access_level + forking_access_level merge_requests_access_level repository_access_level snippets_access_level @@ -483,6 +488,10 @@ class ProjectsController < Projects::ApplicationController def rate_limiter ::Gitlab::ApplicationRateLimiter end + + def render_edit + render 'edit' + end end ProjectsController.prepend_if_ee('EE::ProjectsController') diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index 5fc7f5c84f05c346447816fe4b5e455bfd6f68cf..c0ba87bf3edcfec42cd0e7590f8733f505b1692e 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -60,7 +60,7 @@ class RegistrationsController < Devise::RegistrationsController end def update_registration - user_params = params.require(:user).permit(:name, :role, :setup_for_company) + user_params = params.require(:user).permit(:role, :setup_for_company) result = ::Users::SignupService.new(current_user, user_params).execute if result[:status] == :success @@ -152,13 +152,7 @@ class RegistrationsController < Devise::RegistrationsController end def sign_up_params - clean_params = params.require(:user).permit(:username, :email, :email_confirmation, :name, :password) - - if experiment_enabled?(:signup_flow) - clean_params[:name] = clean_params[:username] - end - - clean_params + params.require(:user).permit(:username, :email, :email_confirmation, :name, :first_name, :last_name, :password) end def resource_name diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb index e30935be4b6b96c3875517defadfc3525858c72b..04d2b3068da1142522afeaad24e2c0245a5225da 100644 --- a/app/controllers/search_controller.rb +++ b/app/controllers/search_controller.rb @@ -21,6 +21,8 @@ class SearchController < ApplicationController return if params[:search].blank? + return unless search_term_valid? + @search_term = params[:search] @scope = search_service.scope @@ -62,6 +64,20 @@ class SearchController < ApplicationController private + def search_term_valid? + unless search_service.valid_query_length? + flash[:alert] = t('errors.messages.search_chars_too_long', count: SearchService::SEARCH_CHAR_LIMIT) + return false + end + + unless search_service.valid_terms_count? + flash[:alert] = t('errors.messages.search_terms_too_long', count: SearchService::SEARCH_TERM_LIMIT) + return false + end + + true + end + def render_commits @search_objects = prepare_commits_for_rendering(@search_objects) end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 0007d5826ba0eef538184053393502512abaea62..c29e9d3843bfe426f43aa61d1eec049381f6e51e 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -262,7 +262,7 @@ class SessionsController < Devise::SessionsController def log_user_activity(user) login_counter.increment - Users::ActivityService.new(user, 'login').execute + Users::ActivityService.new(user).execute end def load_recaptcha diff --git a/app/controllers/snippets_controller.rb b/app/controllers/snippets_controller.rb index 54774df5e761cc12c11a0fdc50b661008f6d84d2..fc073e4736805eb00542272be8bdce1ddbde2c24 100644 --- a/app/controllers/snippets_controller.rb +++ b/app/controllers/snippets_controller.rb @@ -50,8 +50,8 @@ class SnippetsController < ApplicationController def create create_params = snippet_params.merge(spammable_params) - - @snippet = CreateSnippetService.new(nil, current_user, create_params).execute + service_response = Snippets::CreateService.new(nil, current_user, create_params).execute + @snippet = service_response.payload[:snippet] move_temporary_files if @snippet.valid? && params[:files] @@ -61,7 +61,8 @@ class SnippetsController < ApplicationController def update update_params = snippet_params.merge(spammable_params) - UpdateSnippetService.new(nil, current_user, @snippet, update_params).execute + service_response = Snippets::UpdateService.new(nil, current_user, update_params).execute(@snippet) + @snippet = service_response.payload[:snippet] recaptcha_check_with_fallback { render :edit } end @@ -96,11 +97,17 @@ class SnippetsController < ApplicationController end def destroy - return access_denied! unless can?(current_user, :admin_personal_snippet, @snippet) - - @snippet.destroy + service_response = Snippets::DestroyService.new(current_user, @snippet).execute - redirect_to snippets_path, status: :found + if service_response.success? + redirect_to dashboard_snippets_path, status: :found + elsif service_response.http_status == 403 + access_denied! + else + redirect_to snippet_path(@snippet), + status: :found, + alert: service_response.message + end end protected diff --git a/app/finders/deployments_finder.rb b/app/finders/deployments_finder.rb index b718b55dd681b2a12b68747cf7c818317199b74e..0bb8ce6b4da4babff955d4d93d76fa8903d5c62d 100644 --- a/app/finders/deployments_finder.rb +++ b/app/finders/deployments_finder.rb @@ -17,6 +17,8 @@ class DeploymentsFinder def execute items = init_collection items = by_updated_at(items) + items = by_environment(items) + items = by_status(items) sort(items) end @@ -58,6 +60,24 @@ class DeploymentsFinder items end + def by_environment(items) + if params[:environment].present? + items.for_environment_name(params[:environment]) + else + items + end + end + + def by_status(items) + return items unless params[:status].present? + + unless Deployment.statuses.key?(params[:status]) + raise ArgumentError, "The deployment status #{params[:status]} is invalid" + end + + items.for_status(params[:status]) + end + def sort_params order_by = ALLOWED_SORT_VALUES.include?(params[:order_by]) ? params[:order_by] : DEFAULT_SORT_VALUE order_direction = ALLOWED_SORT_DIRECTIONS.include?(params[:sort]) ? params[:sort] : DEFAULT_SORT_DIRECTION diff --git a/app/finders/environments_finder.rb b/app/finders/environments_finder.rb index d4e803beb4ef2a4c063b560d403c822ad4922586..32942c46208b13fd2434b8d1cf8c3475c1b85066 100644 --- a/app/finders/environments_finder.rb +++ b/app/finders/environments_finder.rb @@ -25,25 +25,13 @@ class EnvironmentsFinder .select(:environment_id) environments = project.environments.available - .where(id: environment_ids).order_by_last_deployed_at.to_a + .where(id: environment_ids) - environments.select! do |environment| - Ability.allowed?(current_user, :read_environment, environment) - end - - if ref && commit - environments.select! do |environment| - environment.includes_commit?(commit) - end - end - - if ref && params[:recently_updated] - environments.select! do |environment| - environment.recently_updated_on_branch?(ref) - end + if params[:find_latest] + find_one(environments.order_by_last_deployed_at_desc) + else + find_all(environments.order_by_last_deployed_at.to_a) end - - environments end # rubocop: enable CodeReuse/ActiveRecord @@ -62,6 +50,24 @@ class EnvironmentsFinder private + def find_one(environments) + [environments.find { |environment| valid_environment?(environment) }].compact + end + + def find_all(environments) + environments.select { |environment| valid_environment?(environment) } + end + + def valid_environment?(environment) + # Go in order of cost: SQL calls are cheaper than Gitaly calls + return false unless Ability.allowed?(current_user, :read_environment, environment) + + return false if ref && params[:recently_updated] && !environment.recently_updated_on_branch?(ref) + return false if ref && commit && !environment.includes_commit?(commit) + + true + end + def ref params[:ref].try(:to_s) end diff --git a/app/finders/events_finder.rb b/app/finders/events_finder.rb index 234b7090fd922594e4309156d6e35af0152b3d8d..6d059e10d05de548b1beac62bc3fcbeaab8aa42b 100644 --- a/app/finders/events_finder.rb +++ b/app/finders/events_finder.rb @@ -6,7 +6,7 @@ class EventsFinder MAX_PER_PAGE = 100 - attr_reader :source, :params, :current_user + attr_reader :source, :params, :current_user, :scope requires_cross_project_access unless: -> { source.is_a?(Project) }, model: Event @@ -15,6 +15,7 @@ class EventsFinder # Arguments: # source - which user or project to looks for events on # current_user - only return events for projects visible to this user + # scope - return all events across a user's projects # params: # action: string # target_type: string @@ -27,11 +28,12 @@ class EventsFinder def initialize(params = {}) @source = params.delete(:source) @current_user = params.delete(:current_user) + @scope = params.delete(:scope) @params = params end def execute - events = source.events + events = get_events events = by_current_user_access(events) events = by_action(events) @@ -47,6 +49,12 @@ class EventsFinder private + def get_events + return EventCollection.new(current_user.authorized_projects).all_project_events if scope == 'all' + + source.events + end + # rubocop: disable CodeReuse/ActiveRecord def by_current_user_access(events) events.merge(Project.public_or_visible_to_user(current_user)) diff --git a/app/finders/group_members_finder.rb b/app/finders/group_members_finder.rb index d8739c350e4cc916f098b4360c2d3c69747de004..ffa1552627a578bab9aa63ab778f51542cbb0e66 100644 --- a/app/finders/group_members_finder.rb +++ b/app/finders/group_members_finder.rb @@ -1,38 +1,66 @@ # frozen_string_literal: true class GroupMembersFinder < UnionFinder - def initialize(group) + # Params can be any of the following: + # two_factor: string. 'enabled' or 'disabled' are returning different set of data, other values are not effective. + # sort: string + # search: string + + def initialize(group, user = nil) @group = group + @user = user end # rubocop: disable CodeReuse/ActiveRecord - def execute(include_relations: [:inherited, :direct]) - group_members = @group.members + def execute(include_relations: [:inherited, :direct], params: {}) + group_members = group.members relations = [] return group_members if include_relations == [:direct] relations << group_members if include_relations.include?(:direct) - if include_relations.include?(:inherited) && @group.parent + if include_relations.include?(:inherited) && group.parent parents_members = GroupMember.non_request - .where(source_id: @group.ancestors.select(:id)) - .where.not(user_id: @group.users.select(:id)) + .where(source_id: group.ancestors.select(:id)) + .where.not(user_id: group.users.select(:id)) relations << parents_members end if include_relations.include?(:descendants) descendant_members = GroupMember.non_request - .where(source_id: @group.descendants.select(:id)) - .where.not(user_id: @group.users.select(:id)) + .where(source_id: group.descendants.select(:id)) + .where.not(user_id: group.users.select(:id)) relations << descendant_members end - find_union(relations, GroupMember) + return GroupMember.none if relations.empty? + + members = find_union(relations, GroupMember) + filter_members(members, params) end # rubocop: enable CodeReuse/ActiveRecord + + private + + attr_reader :user, :group + + def filter_members(members, params) + members = members.search(params[:search]) if params[:search].present? + members = members.sort_by_attribute(params[:sort]) if params[:sort].present? + + if can_manage_members && params[:two_factor].present? + members = members.filter_by_2fa(params[:two_factor]) + end + + members + end + + def can_manage_members + Ability.allowed?(user, :admin_group_member, group) + end end GroupMembersFinder.prepend_if_ee('EE::GroupMembersFinder') diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb index e3ea81d55642fc35f320c9acd09cd9d7feba601e..194d7da1cab24506f132897484b9ead785ff4b2b 100644 --- a/app/finders/issuable_finder.rb +++ b/app/finders/issuable_finder.rb @@ -87,7 +87,7 @@ class IssuableFinder end def valid_params - @valid_params ||= scalar_params + [array_params] + [{ not: [] }] + @valid_params ||= scalar_params + [array_params.merge(not: {})] end end diff --git a/app/finders/merge_requests_finder.rb b/app/finders/merge_requests_finder.rb index 275a01330bf1b78ce26316c38e252c77a9f85dc5..410ad645cd9457b93dcfb6dc31ce36e931792728 100644 --- a/app/finders/merge_requests_finder.rb +++ b/app/finders/merge_requests_finder.rb @@ -39,6 +39,7 @@ class MergeRequestsFinder < IssuableFinder def filter_items(_items) items = by_commit(super) + items = by_deployment(items) items = by_source_branch(items) items = by_wip(items) items = by_target_branch(items) @@ -101,6 +102,17 @@ class MergeRequestsFinder < IssuableFinder .or(table[:title].matches('WIP %')) .or(table[:title].matches('[WIP]%')) end + + def by_deployment(items) + return items unless deployment_id + + items.includes(:deployment_merge_requests) + .where(deployment_merge_requests: { deployment_id: deployment_id }) + end + + def deployment_id + @deployment_id ||= params[:deployment_id].presence + end end MergeRequestsFinder.prepend_if_ee('EE::MergeRequestsFinder') diff --git a/app/finders/pipelines_finder.rb b/app/finders/pipelines_finder.rb index 5a0d53d968317289ef429150d37c64b7394c027d..48da44123f6aa78bbcde4dd47d3c5425a4c10fc7 100644 --- a/app/finders/pipelines_finder.rb +++ b/app/finders/pipelines_finder.rb @@ -17,7 +17,7 @@ class PipelinesFinder return Ci::Pipeline.none end - items = pipelines + items = pipelines.no_child items = by_scope(items) items = by_status(items) items = by_ref(items) diff --git a/app/finders/sentry_issue_finder.rb b/app/finders/sentry_issue_finder.rb new file mode 100644 index 0000000000000000000000000000000000000000..8b3e710521127cf602f597888e68f224774cd71d --- /dev/null +++ b/app/finders/sentry_issue_finder.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class SentryIssueFinder + attr_accessor :project, :current_user + + def initialize(project, current_user: nil) + @project = project + @current_user = current_user + end + + def execute(identifier) + return unless authorized? + + SentryIssue + .for_project_and_identifier(project, identifier) + end + + private + + def authorized? + Ability.allowed?(current_user, :read_sentry_issue, project) + end +end diff --git a/app/graphql/gitlab_schema.rb b/app/graphql/gitlab_schema.rb index a5ddf31657215d94e901af5330b49182789a0072..ea5776534d5c5b39e95be53f0703c87beb69aeae 100644 --- a/app/graphql/gitlab_schema.rb +++ b/app/graphql/gitlab_schema.rb @@ -57,13 +57,24 @@ class GitlabSchema < GraphQL::Schema object.to_global_id end - def object_from_id(global_id, _ctx = nil) - gid = GlobalID.parse(global_id) - - unless gid - raise Gitlab::Graphql::Errors::ArgumentError, "#{global_id} is not a valid GitLab id." - end + # Find an object by looking it up from its global ID, passed as a string. + # + # This is the composition of 'parse_gid' and 'find_by_gid', see these + # methods for further documentation. + def object_from_id(global_id, ctx = {}) + gid = parse_gid(global_id, ctx) + + find_by_gid(gid) + end + # Find an object by looking it up from its 'GlobalID'. + # + # * For `ApplicationRecord`s, this is equivalent to + # `global_id.model_class.find(gid.model_id)`, but more efficient. + # * For classes that implement `.lazy_find(global_id)`, this class method + # will be called. + # * All other classes will use `GlobalID#find` + def find_by_gid(gid) if gid.model_class < ApplicationRecord Gitlab::Graphql::Loaders::BatchModelLoader.new(gid.model_class, gid.model_id).find elsif gid.model_class.respond_to?(:lazy_find) @@ -73,6 +84,38 @@ class GitlabSchema < GraphQL::Schema end end + # Parse a string to a GlobalID, raising ArgumentError if there are problems + # with it. + # + # Problems that may occur: + # * it may not be syntactically valid + # * it may not match the expected type (see below) + # + # Options: + # * :expected_type [Class] - the type of object this GlobalID should refer to. + # + # e.g. + # + # ``` + # gid = GitlabSchema.parse_gid(my_string, expected_type: ::Project) + # project_id = gid.model_id + # gid.model_class == ::Project + # ``` + def parse_gid(global_id, ctx = {}) + expected_type = ctx[:expected_type] + gid = GlobalID.parse(global_id) + + raise Gitlab::Graphql::Errors::ArgumentError, "#{global_id} is not a valid GitLab id." unless gid + + if expected_type && !gid.model_class.ancestors.include?(expected_type) + vars = { global_id: global_id, expected_type: expected_type } + msg = _('%{global_id} is not a valid id for %{expected_type}.') % vars + raise Gitlab::Graphql::Errors::ArgumentError, msg + end + + gid + end + private def max_query_complexity(ctx) diff --git a/app/graphql/mutations/award_emojis/toggle.rb b/app/graphql/mutations/award_emojis/toggle.rb index d822048f3a6e11ebc1cfa7fd2a1edd303a7afae3..22eab4812a1327989c89573965c1e0e1ec6997d1 100644 --- a/app/graphql/mutations/award_emojis/toggle.rb +++ b/app/graphql/mutations/award_emojis/toggle.rb @@ -5,10 +5,9 @@ module Mutations class Toggle < Base graphql_name 'ToggleAwardEmoji' - field :toggledOn, - GraphQL::BOOLEAN_TYPE, - null: false, - description: 'True when the emoji was awarded, false when it was removed' + field :toggledOn, GraphQL::BOOLEAN_TYPE, null: false, + description: 'Indicates the status of the emoji. ' \ + 'True if the toggle awarded the emoji, and false if the toggle removed the emoji.' def resolve(args) awardable = authorized_find!(id: args[:awardable_id]) diff --git a/app/graphql/mutations/snippets/create.rb b/app/graphql/mutations/snippets/create.rb index fe1f543ea1a7c9516520192685a45f8e4ee1d759..4e0e65d09a9e448109c53b6d1aed2eb6c7c2ae56 100644 --- a/app/graphql/mutations/snippets/create.rb +++ b/app/graphql/mutations/snippets/create.rb @@ -42,12 +42,13 @@ module Mutations if project_path.present? project = find_project!(project_path: project_path) elsif !can_create_personal_snippet? - raise_resource_not_avaiable_error! + raise_resource_not_available_error! end - snippet = CreateSnippetService.new(project, + service_response = ::Snippets::CreateService.new(project, context[:current_user], args).execute + snippet = service_response.payload[:snippet] { snippet: snippet.valid? ? snippet : nil, diff --git a/app/graphql/mutations/snippets/destroy.rb b/app/graphql/mutations/snippets/destroy.rb index 115fcfd6488cb43bba4d9ba2687abd4f1b53caac..dc9a1e8257589bf98da2659f9ed6fc47844fb08e 100644 --- a/app/graphql/mutations/snippets/destroy.rb +++ b/app/graphql/mutations/snippets/destroy.rb @@ -15,8 +15,8 @@ module Mutations def resolve(id:) snippet = authorized_find!(id: id) - result = snippet.destroy - errors = result ? [] : [ERROR_MSG] + response = ::Snippets::DestroyService.new(current_user, snippet).execute + errors = response.success? ? [] : [ERROR_MSG] { errors: errors diff --git a/app/graphql/mutations/snippets/mark_as_spam.rb b/app/graphql/mutations/snippets/mark_as_spam.rb index 260a9753f76e7d76f3ff3dc4031d96401a6b168f..8cfbbae7c08861794ce23ca3946b523733d263a6 100644 --- a/app/graphql/mutations/snippets/mark_as_spam.rb +++ b/app/graphql/mutations/snippets/mark_as_spam.rb @@ -24,7 +24,7 @@ module Mutations private def mark_as_spam(snippet) - SpamService.new(snippet).mark_as_spam! + Spam::MarkAsSpamService.new(spammable: snippet).execute end def authorized_resource?(snippet) diff --git a/app/graphql/mutations/snippets/update.rb b/app/graphql/mutations/snippets/update.rb index 27c232bc7f8a071107506af127abcea535f8f271..b6bdcb9b67b60a00cca85507302ff474bae5511b 100644 --- a/app/graphql/mutations/snippets/update.rb +++ b/app/graphql/mutations/snippets/update.rb @@ -33,13 +33,13 @@ module Mutations def resolve(args) snippet = authorized_find!(id: args.delete(:id)) - result = UpdateSnippetService.new(snippet.project, + result = ::Snippets::UpdateService.new(snippet.project, context[:current_user], - snippet, - args).execute + args).execute(snippet) + snippet = result.payload[:snippet] { - snippet: result ? snippet : snippet.reset, + snippet: result.success? ? snippet : snippet.reset, errors: errors_on_object(snippet) } end diff --git a/app/graphql/resolvers/base_resolver.rb b/app/graphql/resolvers/base_resolver.rb index 62dcc41dd9c3afb2c9d520c33186cea2b75808c7..f2b015edfa198351b9141c01b08634b77b3bbe48 100644 --- a/app/graphql/resolvers/base_resolver.rb +++ b/app/graphql/resolvers/base_resolver.rb @@ -9,6 +9,10 @@ module Resolvers def resolve(**args) super.first end + + def single? + true + end end end @@ -17,6 +21,10 @@ module Resolvers def resolve(**args) super.last end + + def single? + true + end end end @@ -42,9 +50,13 @@ module Resolvers override :object def object super.tap do |obj| - # If the field this resolver is used in is wrapped in a presenter, go back to it's subject + # If the field this resolver is used in is wrapped in a presenter, unwrap its subject break obj.subject if obj.is_a?(Gitlab::View::Presenter::Base) end end + + def single? + false + end end end diff --git a/app/graphql/resolvers/environments_resolver.rb b/app/graphql/resolvers/environments_resolver.rb new file mode 100644 index 0000000000000000000000000000000000000000..868abef98eba8ea2c5d9ad4846a9d7b2b817e1b6 --- /dev/null +++ b/app/graphql/resolvers/environments_resolver.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Resolvers + class EnvironmentsResolver < BaseResolver + argument :name, GraphQL::STRING_TYPE, + required: false, + description: 'Name of the environment' + + argument :search, GraphQL::STRING_TYPE, + required: false, + description: 'Search query' + + type Types::EnvironmentType, null: true + + alias_method :project, :object + + def resolve(**args) + return unless project.present? + + EnvironmentsFinder.new(project, context[:current_user], args).find + end + end +end diff --git a/app/graphql/resolvers/projects/grafana_integration_resolver.rb b/app/graphql/resolvers/projects/grafana_integration_resolver.rb new file mode 100644 index 0000000000000000000000000000000000000000..030139734ed18d2eab57b2aa37f808d648484816 --- /dev/null +++ b/app/graphql/resolvers/projects/grafana_integration_resolver.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Resolvers + module Projects + class GrafanaIntegrationResolver < BaseResolver + type Types::GrafanaIntegrationType, null: true + + alias_method :project, :object + + def resolve(**args) + return unless project.is_a? Project + + project.grafana_integration + end + end + end +end diff --git a/app/graphql/types/award_emojis/award_emoji_type.rb b/app/graphql/types/award_emojis/award_emoji_type.rb index 8daf699a1126d9b81d999f5d7ee12af8ded00a9e..0247ec767c882522fa0e52ae847faf2841c9091d 100644 --- a/app/graphql/types/award_emojis/award_emoji_type.rb +++ b/app/graphql/types/award_emojis/award_emoji_type.rb @@ -4,6 +4,7 @@ module Types module AwardEmojis class AwardEmojiType < BaseObject graphql_name 'AwardEmoji' + description 'An emoji awarded by a user.' authorize :read_emoji diff --git a/app/graphql/types/ci/detailed_status_type.rb b/app/graphql/types/ci/detailed_status_type.rb index d2847641d9137069533b036e11e6eeb820dd37e7..90b5283fc9aadb87d2c699c48f70dac648078aab 100644 --- a/app/graphql/types/ci/detailed_status_type.rb +++ b/app/graphql/types/ci/detailed_status_type.rb @@ -6,14 +6,24 @@ module Types class DetailedStatusType < BaseObject graphql_name 'DetailedStatus' - field :group, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :icon, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :favicon, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :details_path, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :has_details, GraphQL::BOOLEAN_TYPE, null: false, method: :has_details? # rubocop:disable Graphql/Descriptions - field :label, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :text, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :tooltip, GraphQL::STRING_TYPE, null: false, method: :status_tooltip # rubocop:disable Graphql/Descriptions + field :group, GraphQL::STRING_TYPE, null: false, + description: 'Group of the pipeline status' + field :icon, GraphQL::STRING_TYPE, null: false, + description: 'Icon of the pipeline status' + field :favicon, GraphQL::STRING_TYPE, null: false, + description: 'Favicon of the pipeline status' + field :details_path, GraphQL::STRING_TYPE, null: false, + description: 'Path of the details for the pipeline status' + field :has_details, GraphQL::BOOLEAN_TYPE, null: false, + description: 'Indicates if the pipeline status has further details', + method: :has_details? + field :label, GraphQL::STRING_TYPE, null: false, + description: 'Label of the pipeline status' + field :text, GraphQL::STRING_TYPE, null: false, + description: 'Text of the pipeline status' + field :tooltip, GraphQL::STRING_TYPE, null: false, + description: 'Tooltip associated with the pipeline status', + method: :status_tooltip end # rubocop: enable Graphql/AuthorizeTypes end diff --git a/app/graphql/types/ci/pipeline_type.rb b/app/graphql/types/ci/pipeline_type.rb index dfcfd6211bcf51b9931b7c1e4f9c81ac9eec4b40..d77b2a2ba328c85bf90cfbe8e9dad68edd241996 100644 --- a/app/graphql/types/ci/pipeline_type.rb +++ b/app/graphql/types/ci/pipeline_type.rb @@ -9,29 +9,34 @@ module Types expose_permissions Types::PermissionTypes::Ci::Pipeline - field :id, GraphQL::ID_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :iid, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions + field :id, GraphQL::ID_TYPE, null: false, + description: 'ID of the pipeline' + field :iid, GraphQL::STRING_TYPE, null: false, + description: 'Internal ID of the pipeline' - field :sha, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :before_sha, GraphQL::STRING_TYPE, null: true # rubocop:disable Graphql/Descriptions - field :status, PipelineStatusEnum, null: false # rubocop:disable Graphql/Descriptions - field :detailed_status, # rubocop:disable Graphql/Descriptions - Types::Ci::DetailedStatusType, - null: false, + field :sha, GraphQL::STRING_TYPE, null: false, + description: "SHA of the pipeline's commit" + field :before_sha, GraphQL::STRING_TYPE, null: true, + description: 'Base SHA of the source branch' + field :status, PipelineStatusEnum, null: false, + description: "Status of the pipeline (#{::Ci::Pipeline.all_state_names.compact.join(', ').upcase})" + field :detailed_status, Types::Ci::DetailedStatusType, null: false, + description: 'Detailed status of the pipeline', resolve: -> (obj, _args, ctx) { obj.detailed_status(ctx[:current_user]) } - field :duration, - GraphQL::INT_TYPE, - null: true, - description: "Duration of the pipeline in seconds" - field :coverage, - GraphQL::FLOAT_TYPE, - null: true, - description: "Coverage percentage" - field :created_at, Types::TimeType, null: false # rubocop:disable Graphql/Descriptions - field :updated_at, Types::TimeType, null: false # rubocop:disable Graphql/Descriptions - field :started_at, Types::TimeType, null: true # rubocop:disable Graphql/Descriptions - field :finished_at, Types::TimeType, null: true # rubocop:disable Graphql/Descriptions - field :committed_at, Types::TimeType, null: true # rubocop:disable Graphql/Descriptions + field :duration, GraphQL::INT_TYPE, null: true, + description: 'Duration of the pipeline in seconds' + field :coverage, GraphQL::FLOAT_TYPE, null: true, + description: 'Coverage percentage' + field :created_at, Types::TimeType, null: false, + description: "Timestamp of the pipeline's creation" + field :updated_at, Types::TimeType, null: false, + description: "Timestamp of the pipeline's last activity" + field :started_at, Types::TimeType, null: true, + description: 'Timestamp when the pipeline was started' + field :finished_at, Types::TimeType, null: true, + description: "Timestamp of the pipeline's completion" + field :committed_at, Types::TimeType, null: true, + description: "Timestamp of the pipeline's commit" # TODO: Add triggering user as a type end diff --git a/app/graphql/types/environment_type.rb b/app/graphql/types/environment_type.rb new file mode 100644 index 0000000000000000000000000000000000000000..ad65caa24a6b6f4560a92cf8200ce582e34287fa --- /dev/null +++ b/app/graphql/types/environment_type.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Types + class EnvironmentType < BaseObject + graphql_name 'Environment' + description 'Describes where code is deployed for a project' + + authorize :read_environment + + field :name, GraphQL::STRING_TYPE, null: false, + description: 'Human-readable name of the environment' + + field :id, GraphQL::ID_TYPE, null: false, + description: 'ID of the environment' + end +end diff --git a/app/graphql/types/error_tracking/sentry_detailed_error_type.rb b/app/graphql/types/error_tracking/sentry_detailed_error_type.rb index c680f387a9a87a910bb71fb108e9e3397c9f7206..af6d8818d9097030477815504629129cfd70266e 100644 --- a/app/graphql/types/error_tracking/sentry_detailed_error_type.rb +++ b/app/graphql/types/error_tracking/sentry_detailed_error_type.rb @@ -76,6 +76,12 @@ module Types field :last_release_short_version, GraphQL::STRING_TYPE, null: true, description: "Release version the error was last seen" + field :gitlab_commit, GraphQL::STRING_TYPE, + null: true, + description: "GitLab commit SHA attributed to the Error based on the release version" + field :gitlab_commit_path, GraphQL::STRING_TYPE, + null: true, + description: "Path to the GitLab page for the GitLab commit attributed to the error" def first_seen DateTime.parse(object.first_seen) diff --git a/app/graphql/types/grafana_integration_type.rb b/app/graphql/types/grafana_integration_type.rb new file mode 100644 index 0000000000000000000000000000000000000000..e6c865fea538728ffef91c68743bec97578f91be --- /dev/null +++ b/app/graphql/types/grafana_integration_type.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Types + class GrafanaIntegrationType < ::Types::BaseObject + graphql_name 'GrafanaIntegration' + + authorize :admin_operations + + field :id, GraphQL::ID_TYPE, null: false, + description: 'Internal ID of the Grafana integration' + field :grafana_url, GraphQL::STRING_TYPE, null: false, + description: 'Url for the Grafana host for the Grafana integration' + field :token, GraphQL::STRING_TYPE, null: false, + description: 'API token for the Grafana integration' + field :enabled, GraphQL::BOOLEAN_TYPE, null: false, + description: 'Indicates whether Grafana integration is enabled' + + field :created_at, Types::TimeType, null: false, + description: 'Timestamp of the issue\'s creation' + field :updated_at, Types::TimeType, null: false, + description: 'Timestamp of the issue\'s last activity' + end +end diff --git a/app/graphql/types/group_type.rb b/app/graphql/types/group_type.rb index 386ae6ed4a3b7b84c4d767459c20e68dc39a84c8..393948fcede8b8fdee7bd64503ffc761905d8a7c 100644 --- a/app/graphql/types/group_type.rb +++ b/app/graphql/types/group_type.rb @@ -17,6 +17,9 @@ module Types group.avatar_url(only_path: false) end + field :mentions_disabled, GraphQL::BOOLEAN_TYPE, null: true, + description: 'Indicates if a group is disabled from getting mentioned' + field :parent, GroupType, null: true, description: 'Parent group', resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(Group, obj.parent_id).find } diff --git a/app/graphql/types/issue_type.rb b/app/graphql/types/issue_type.rb index 4cbb849da3a3f0c9d4da9b6045aa917dfb9cb8c9..11850e5865fa857e9b9aee0e8a0cb3f2c254d458 100644 --- a/app/graphql/types/issue_type.rb +++ b/app/graphql/types/issue_type.rb @@ -69,7 +69,7 @@ module Types field :participants, Types::UserType.connection_type, null: true, complexity: 5, description: 'List of participants in the issue' field :subscribed, GraphQL::BOOLEAN_TYPE, method: :subscribed?, null: false, complexity: 5, - description: 'Boolean flag for whether the currently logged in user is subscribed to this issue' + description: 'Indicates the currently logged in user is subscribed to the issue' field :time_estimate, GraphQL::INT_TYPE, null: false, description: 'Time estimate of the issue' field :total_time_spent, GraphQL::INT_TYPE, null: false, diff --git a/app/graphql/types/permission_types/base_permission_type.rb b/app/graphql/types/permission_types/base_permission_type.rb index 73049ebed7bb15371db8c5d5b1d82ec47653a2d6..deb8560bd7973ed7e27d10f1d49c36dfde3d3b71 100644 --- a/app/graphql/types/permission_types/base_permission_type.rb +++ b/app/graphql/types/permission_types/base_permission_type.rb @@ -25,7 +25,7 @@ module Types kword_args = kword_args.reverse_merge( name: name, type: GraphQL::BOOLEAN_TYPE, - description: "Whether or not a user can perform `#{name}` on this resource", + description: "Indicates the user can perform `#{name}` on this resource", null: false) field(**kword_args) # rubocop:disable Graphql/Descriptions diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb index bd80ad7ff7475f5a2c1d169fab2f53511a3ddfa5..5ece492695104cb6e93739a3a314677066e2f8b6 100644 --- a/app/graphql/types/project_type.rb +++ b/app/graphql/types/project_type.rb @@ -46,7 +46,7 @@ module Types description: 'Timestamp of the project last activity' field :archived, GraphQL::BOOLEAN_TYPE, null: true, - description: 'Archived status of the project' + description: 'Indicates the archived status of the project' field :visibility, GraphQL::STRING_TYPE, null: true, description: 'Visibility of the project' @@ -102,6 +102,10 @@ module Types description: 'Indicates if a link to create or view a merge request should display after a push to Git repositories of the project from the command line' field :remove_source_branch_after_merge, GraphQL::BOOLEAN_TYPE, null: true, description: 'Indicates if `Delete source branch` option should be enabled by default for all new merge requests of the project' + field :autoclose_referenced_issues, GraphQL::BOOLEAN_TYPE, null: true, + description: 'Indicates if issues referenced by merge requests and commits within the default branch are closed automatically' + field :suggestion_commit_message, GraphQL::STRING_TYPE, null: true, + description: 'The commit message used to apply merge request suggestions' field :namespace, Types::NamespaceType, null: true, description: 'Namespace of the project' @@ -134,6 +138,12 @@ module Types description: 'Issues of the project', resolver: Resolvers::IssuesResolver + field :environments, + Types::EnvironmentType.connection_type, + null: true, + description: 'Environments of the project', + resolver: Resolvers::EnvironmentsResolver + field :issue, Types::IssueType, null: true, @@ -152,6 +162,12 @@ module Types description: 'Detailed version of a Sentry error on the project', resolver: Resolvers::ErrorTracking::SentryDetailedErrorResolver + field :grafana_integration, + Types::GrafanaIntegrationType, + null: true, + description: 'Grafana integration details for the project', + resolver: Resolvers::Projects::GrafanaIntegrationResolver + field :snippets, Types::SnippetType.connection_type, null: true, diff --git a/app/graphql/types/tree/blob_type.rb b/app/graphql/types/tree/blob_type.rb index 0886a0ba98e9197d527014191a2fdb10e24ba165..22349203519c6a3004bdfe16a47dc68ac1cf28a5 100644 --- a/app/graphql/types/tree/blob_type.rb +++ b/app/graphql/types/tree/blob_type.rb @@ -10,10 +10,13 @@ module Types graphql_name 'Blob' - field :web_url, GraphQL::STRING_TYPE, null: true # rubocop:disable Graphql/Descriptions - field :lfs_oid, GraphQL::STRING_TYPE, null: true, resolve: -> (blob, args, ctx) do # rubocop:disable Graphql/Descriptions - Gitlab::Graphql::Loaders::BatchLfsOidLoader.new(blob.repository, blob.id).find - end + field :web_url, GraphQL::STRING_TYPE, null: true, + description: 'Web URL of the blob' + field :lfs_oid, GraphQL::STRING_TYPE, null: true, + description: 'LFS ID of the blob', + resolve: -> (blob, args, ctx) do + Gitlab::Graphql::Loaders::BatchLfsOidLoader.new(blob.repository, blob.id).find + end # rubocop: enable Graphql/AuthorizeTypes end end diff --git a/app/graphql/types/tree/entry_type.rb b/app/graphql/types/tree/entry_type.rb index 87a3eced896cf98e32d3c480f7e9f0511d45c2d8..b40e38ec9d1e7ab254014021cca58b3313023a7e 100644 --- a/app/graphql/types/tree/entry_type.rb +++ b/app/graphql/types/tree/entry_type.rb @@ -4,12 +4,18 @@ module Types module EntryType include Types::BaseInterface - field :id, GraphQL::ID_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :sha, GraphQL::STRING_TYPE, null: false, description: "Last commit sha for entry", method: :id - field :name, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :type, Tree::TypeEnum, null: false # rubocop:disable Graphql/Descriptions - field :path, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :flat_path, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions + field :id, GraphQL::ID_TYPE, null: false, + description: 'ID of the entry' + field :sha, GraphQL::STRING_TYPE, null: false, + description: 'Last commit sha for the entry', method: :id + field :name, GraphQL::STRING_TYPE, null: false, + description: 'Name of the entry' + field :type, Tree::TypeEnum, null: false, + description: 'Type of tree entry' + field :path, GraphQL::STRING_TYPE, null: false, + description: 'Path of the entry' + field :flat_path, GraphQL::STRING_TYPE, null: false, + description: 'Flat path of the entry' end end end diff --git a/app/graphql/types/tree/submodule_type.rb b/app/graphql/types/tree/submodule_type.rb index d8e2ab4dd68d911becdfb503e24885432e380126..d41fa4afd4ba922fbb1fb629c7d000d81ba0cb5f 100644 --- a/app/graphql/types/tree/submodule_type.rb +++ b/app/graphql/types/tree/submodule_type.rb @@ -8,8 +8,10 @@ module Types graphql_name 'Submodule' - field :web_url, type: GraphQL::STRING_TYPE, null: true # rubocop:disable Graphql/Descriptions - field :tree_url, type: GraphQL::STRING_TYPE, null: true # rubocop:disable Graphql/Descriptions + field :web_url, type: GraphQL::STRING_TYPE, null: true, + description: 'Web URL for the sub-module' + field :tree_url, type: GraphQL::STRING_TYPE, null: true, + description: 'Tree URL for the sub-module' end # rubocop: enable Graphql/AuthorizeTypes end diff --git a/app/graphql/types/tree/tree_entry_type.rb b/app/graphql/types/tree/tree_entry_type.rb index 904c7dfb795171a7f01297604082ba74687370b7..81a7a7e66aeae548032d5166a416e944f712ec5e 100644 --- a/app/graphql/types/tree/tree_entry_type.rb +++ b/app/graphql/types/tree/tree_entry_type.rb @@ -11,7 +11,8 @@ module Types graphql_name 'TreeEntry' description 'Represents a directory' - field :web_url, GraphQL::STRING_TYPE, null: true # rubocop:disable Graphql/Descriptions + field :web_url, GraphQL::STRING_TYPE, null: true, + description: 'Web URL for the tree entry (directory)' end # rubocop: enable Graphql/AuthorizeTypes end diff --git a/app/graphql/types/tree/tree_type.rb b/app/graphql/types/tree/tree_type.rb index 56d544b5fd14800e9718b161e3be2b4099fed6d7..b9fb6b28e716ea9b909b9c6a2d304450bbf54d8a 100644 --- a/app/graphql/types/tree/tree_type.rb +++ b/app/graphql/types/tree/tree_type.rb @@ -11,19 +11,23 @@ module Types null: true, complexity: 10, calls_gitaly: true, resolver: Resolvers::LastCommitResolver, description: 'Last commit for the tree' - field :trees, Types::Tree::TreeEntryType.connection_type, null: false, resolve: -> (obj, args, ctx) do # rubocop:disable Graphql/Descriptions - Gitlab::Graphql::Representation::TreeEntry.decorate(obj.trees, obj.repository) - end + field :trees, Types::Tree::TreeEntryType.connection_type, null: false, + description: 'Trees of the tree', + resolve: -> (obj, args, ctx) do + Gitlab::Graphql::Representation::TreeEntry.decorate(obj.trees, obj.repository) + end - # rubocop:disable Graphql/Descriptions - field :submodules, Types::Tree::SubmoduleType.connection_type, null: false, calls_gitaly: true, resolve: -> (obj, args, ctx) do - Gitlab::Graphql::Representation::SubmoduleTreeEntry.decorate(obj.submodules, obj) - end - # rubocop:enable Graphql/Descriptions + field :submodules, Types::Tree::SubmoduleType.connection_type, null: false, + description: 'Sub-modules of the tree', + calls_gitaly: true, resolve: -> (obj, args, ctx) do + Gitlab::Graphql::Representation::SubmoduleTreeEntry.decorate(obj.submodules, obj) + end - field :blobs, Types::Tree::BlobType.connection_type, null: false, calls_gitaly: true, resolve: -> (obj, args, ctx) do # rubocop:disable Graphql/Descriptions - Gitlab::Graphql::Representation::TreeEntry.decorate(obj.blobs, obj.repository) - end + field :blobs, Types::Tree::BlobType.connection_type, null: false, + description: 'Blobs of the tree', + calls_gitaly: true, resolve: -> (obj, args, ctx) do + Gitlab::Graphql::Representation::TreeEntry.decorate(obj.blobs, obj.repository) + end # rubocop: enable Graphql/AuthorizeTypes end end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 8389272fd355c6294a16cf24a475f4de80c60e26..8833b36c42db4de9cc87b58ed8825438f32a9549 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -198,7 +198,7 @@ module ApplicationHelper end def external_storage_url_or_path(path, project = @project) - return path unless static_objects_external_storage_enabled? + return path if @snippet || !static_objects_external_storage_enabled? uri = URI(Gitlab::CurrentSettings.static_objects_external_storage_url) path = URI(path) # `path` could have query parameters, so we need to split query and path apart diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb index 71e4195c50fca06f5355d356e1aaa22d75da9760..0e14db6ddbf38b1df3c14f8d2d364136bc2449aa 100644 --- a/app/helpers/application_settings_helper.rb +++ b/app/helpers/application_settings_helper.rb @@ -202,6 +202,7 @@ module ApplicationSettingsHelper :enabled_git_access_protocol, :enforce_terms, :first_day_of_week, + :force_pages_access_control, :gitaly_timeout_default, :gitaly_timeout_medium, :gitaly_timeout_fast, @@ -334,6 +335,28 @@ module ApplicationSettingsHelper def omnibus_protected_paths_throttle? Rack::Attack.throttles.key?('protected paths') end + + def self_monitoring_project_data + { + 'create_self_monitoring_project_path' => + create_self_monitoring_project_admin_application_settings_path, + + 'status_create_self_monitoring_project_path' => + status_create_self_monitoring_project_admin_application_settings_path, + + 'delete_self_monitoring_project_path' => + delete_self_monitoring_project_admin_application_settings_path, + + 'status_delete_self_monitoring_project_path' => + status_delete_self_monitoring_project_admin_application_settings_path, + + 'self_monitoring_project_exists' => + Gitlab::CurrentSettings.instance_administration_project.present?.to_s, + + 'self_monitoring_project_full_path' => + Gitlab::CurrentSettings.instance_administration_project&.full_path + } + end end ApplicationSettingsHelper.prepend_if_ee('EE::ApplicationSettingsHelper') # rubocop: disable Cop/InjectEnterpriseEditionModule diff --git a/app/helpers/broadcast_messages_helper.rb b/app/helpers/broadcast_messages_helper.rb index 21e57a8d391bfe088deb22cbcbec9ad08cef5252..b95fd8800c0e6748d131944a9b692d6e74af8f01 100644 --- a/app/helpers/broadcast_messages_helper.rb +++ b/app/helpers/broadcast_messages_helper.rb @@ -1,19 +1,29 @@ # frozen_string_literal: true module BroadcastMessagesHelper - def current_broadcast_messages - BroadcastMessage.current(request.path) + def current_broadcast_banner_messages + BroadcastMessage.current_banner_messages(request.path) end - def broadcast_message(message) + def current_broadcast_notification_message + BroadcastMessage.current_notification_messages(request.path).last + end + + def broadcast_message(message, opts = {}) return unless message.present? - content_tag :div, dir: 'auto', class: 'broadcast-message', style: broadcast_message_style(message) do - sprite_icon('bullhorn', size: 16, css_class: 'vertical-align-text-top mr-2') << ' ' << render_broadcast_message(message) + classes = "broadcast-#{message.broadcast_type}-message #{opts[:preview] && 'preview'}" + + content_tag :div, dir: 'auto', class: classes, style: broadcast_message_style(message) do + concat sprite_icon('bullhorn', size: 16, css_class: 'vertical-align-text-top') + concat ' ' + concat render_broadcast_message(message) end end def broadcast_message_style(broadcast_message) + return '' if broadcast_message.notification? + style = [] if broadcast_message.color.present? @@ -40,4 +50,8 @@ module BroadcastMessagesHelper def render_broadcast_message(broadcast_message) Banzai.render_field(broadcast_message, :message).html_safe end + + def broadcast_type_options + BroadcastMessage.broadcast_types.keys.map { |w| [w.humanize, w] } + end end diff --git a/app/helpers/ci_status_helper.rb b/app/helpers/ci_status_helper.rb index 4471d5b64b24a5bb1e6dfd4faa8a23eeaa936012..80d1b7e7edbde78d8642f5ae1a22eedcb7f9044f 100644 --- a/app/helpers/ci_status_helper.rb +++ b/app/helpers/ci_status_helper.rb @@ -62,6 +62,7 @@ module CiStatusHelper status.humanize end + # rubocop:disable Metrics/CyclomaticComplexity def ci_icon_for_status(status, size: 16) if detailed_status?(status) return sprite_icon(status.icon, size: size) @@ -77,6 +78,8 @@ module CiStatusHelper 'status_failed' when 'pending' 'status_pending' + when 'waiting_for_resource' + 'status_pending' when 'preparing' 'status_preparing' when 'running' @@ -97,6 +100,7 @@ module CiStatusHelper sprite_icon(icon_name, size: size) end + # rubocop:enable Metrics/CyclomaticComplexity def ci_icon_class_for_status(status) group = detailed_status?(status) ? status.group : status.dasherize diff --git a/app/helpers/container_expiration_policies_helper.rb b/app/helpers/container_expiration_policies_helper.rb index 17791e7b0ffc807d915dd15ac80ff1f74c4474d3..5fb7b5afa6e165f626b7daf539c0b9f7f5e2ac2c 100644 --- a/app/helpers/container_expiration_policies_helper.rb +++ b/app/helpers/container_expiration_policies_helper.rb @@ -3,19 +3,25 @@ module ContainerExpirationPoliciesHelper def cadence_options ContainerExpirationPolicy.cadence_options.map do |key, val| - { key: key.to_s, label: val } + { key: key.to_s, label: val }.tap do |base| + base[:default] = true if key.to_s == '1d' + end end end def keep_n_options ContainerExpirationPolicy.keep_n_options.map do |key, val| - { key: key, label: val } + { key: key, label: val }.tap do |base| + base[:default] = true if key == 10 + end end end def older_than_options ContainerExpirationPolicy.older_than_options.map do |key, val| - { key: key.to_s, label: val } + { key: key.to_s, label: val }.tap do |base| + base[:default] = true if key.to_s == '30d' + end end end end diff --git a/app/helpers/dashboard_helper.rb b/app/helpers/dashboard_helper.rb index 679622897aaa5459ce09efba3460a97671188fb5..b38feb0fb6c712fb4ea7767ee751894301fd8441 100644 --- a/app/helpers/dashboard_helper.rb +++ b/app/helpers/dashboard_helper.rb @@ -35,7 +35,7 @@ module DashboardHelper tag.p(aria: { label: label }) do concat(link_or_title) - concat(tag.span(class: ['light', 'float-right']) do + concat(tag.span(class: %w[light float-right]) do boolean_to_icon(enabled) end) @@ -58,6 +58,10 @@ module DashboardHelper links += [:activity, :milestones] end + if can?(current_user, :read_instance_statistics) + links << :analytics + end + links end end diff --git a/app/helpers/environments_helper.rb b/app/helpers/environments_helper.rb index 59972118ae3f1986733237ff91c334d04bf3f395..993c18f9229c9f567059a54ae8d94bf3520ef1f9 100644 --- a/app/helpers/environments_helper.rb +++ b/app/helpers/environments_helper.rb @@ -29,8 +29,10 @@ module EnvironmentsHelper "empty-no-data-small-svg-path" => image_path('illustrations/chart-empty-state-small.svg'), "empty-unable-to-connect-svg-path" => image_path('illustrations/monitoring/unable_to_connect.svg'), "metrics-endpoint" => additional_metrics_project_environment_path(project, environment, format: :json), + "dashboards-endpoint" => project_performance_monitoring_dashboards_path(project, format: :json), "dashboard-endpoint" => metrics_dashboard_project_environment_path(project, environment, format: :json), "deployments-endpoint" => project_environment_deployments_path(project, environment, format: :json), + "default-branch" => project.default_branch, "environments-endpoint": project_environments_path(project, format: :json), "project-path" => project_path(project), "tags-path" => project_tags_path(project), diff --git a/app/helpers/gitlab_routing_helper.rb b/app/helpers/gitlab_routing_helper.rb index 78c41257404338bb9109640f5a859941e12611ee..1fb0b83b0109fbfd60e5281e591b9492a46f3b59 100644 --- a/app/helpers/gitlab_routing_helper.rb +++ b/app/helpers/gitlab_routing_helper.rb @@ -153,6 +153,29 @@ module GitlabRoutingHelper # Artifacts + # Rails path generators are slow because they need to do large regex comparisons + # against the arguments. We can speed this up 10x by generating the strings directly. + + # /*namespace_id/:project_id/-/jobs/:job_id/artifacts/download(.:format) + def fast_download_project_job_artifacts_path(project, job) + expose_fast_artifacts_path(project, job, :download) + end + + # /*namespace_id/:project_id/-/jobs/:job_id/artifacts/keep(.:format) + def fast_keep_project_job_artifacts_path(project, job) + expose_fast_artifacts_path(project, job, :keep) + end + + # /*namespace_id/:project_id/-/jobs/:job_id/artifacts/browse(/*path) + def fast_browse_project_job_artifacts_path(project, job) + expose_fast_artifacts_path(project, job, :browse) + end + + def expose_fast_artifacts_path(project, job, action) + path = "#{project.full_path}/-/jobs/#{job.id}/artifacts/#{action}" + Gitlab::Utils.append_path(Gitlab.config.gitlab.relative_url_root, path) + end + def artifacts_action_path(path, project, build) action, path_params = path.split('/', 2) args = [project, build, path_params] diff --git a/app/helpers/groups/group_members_helper.rb b/app/helpers/groups/group_members_helper.rb index a8f6c974bbdafe6de692eb112d9c5dad235e433a..1952325c504bfda6169fa374eb2de084ddd99d1a 100644 --- a/app/helpers/groups/group_members_helper.rb +++ b/app/helpers/groups/group_members_helper.rb @@ -4,6 +4,10 @@ module Groups::GroupMembersHelper def group_member_select_options { multiple: true, class: 'input-clamp qa-member-select-field ', scope: :all, email_user: true } end + + def render_invite_member_for_group(group, default_access_level) + render 'shared/members/invite_member', submit_url: group_group_members_path(group), access_levels: GroupMember.access_level_roles, default_access_level: default_access_level + end end Groups::GroupMembersHelper.prepend_if_ee('EE::Groups::GroupMembersHelper') diff --git a/app/helpers/ide_helper.rb b/app/helpers/ide_helper.rb index 8e50bbc6c04c188b4d44516e5b13edfb8b181a37..e4d0e605254f0da362edc615d3a6d7058afecf3b 100644 --- a/app/helpers/ide_helper.rb +++ b/app/helpers/ide_helper.rb @@ -10,7 +10,8 @@ module IdeHelper "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 + "clientside-preview-enabled": Gitlab::CurrentSettings.current_application_settings.web_ide_clientside_preview_enabled.to_s, + "render-whitespace-in-code": current_user.render_whitespace_in_code.to_s } end end diff --git a/app/helpers/markup_helper.rb b/app/helpers/markup_helper.rb index e1e756c2f4ce6c79a22c328bb850dd02287a485c..d6e466d4678acd74a5bc9313243e0058433234d7 100644 --- a/app/helpers/markup_helper.rb +++ b/app/helpers/markup_helper.rb @@ -132,6 +132,7 @@ module MarkupHelper pipeline: :wiki, project: @project, project_wiki: @project_wiki, + repository: @project_wiki.repository, page_slug: wiki_page.slug, issuable_state_filter_enabled: true ) @@ -153,7 +154,9 @@ module MarkupHelper else other_markup_unsafe(file_name, text, context) end - rescue RuntimeError + rescue StandardError => e + Gitlab::ErrorTracking.track_exception(e, project_id: @project&.id, file_name: file_name, context: context) + simple_format(text) end @@ -280,7 +283,7 @@ module MarkupHelper context.reverse_merge!( current_user: (current_user if defined?(current_user)), - # RelativeLinkFilter + # RepositoryLinkFilter and UploadLinkFilter commit: @commit, project_wiki: @project_wiki, ref: @ref, diff --git a/app/helpers/projects/error_tracking_helper.rb b/app/helpers/projects/error_tracking_helper.rb index de21a78f5f01e158225251860442f6f2e07dcd80..ed5c7640ec12c85d9009e395e2024d71c89fa9a9 100644 --- a/app/helpers/projects/error_tracking_helper.rb +++ b/app/helpers/projects/error_tracking_helper.rb @@ -10,6 +10,8 @@ module Projects::ErrorTrackingHelper 'user-can-enable-error-tracking' => can?(current_user, :admin_operations, project).to_s, 'enable-error-tracking-link' => project_settings_operations_path(project), 'error-tracking-enabled' => error_tracking_enabled.to_s, + 'project-path' => project.full_path, + 'list-path' => project_error_tracking_index_path(project), 'illustration-path' => image_path('illustrations/cluster_popover.svg') } end @@ -18,8 +20,12 @@ module Projects::ErrorTrackingHelper opts = [project, issue_id, { format: :json }] { - 'project-issues-path' => project_issues_path(project), + 'issue-id' => issue_id, + 'project-path' => project.full_path, + 'list-path' => project_error_tracking_index_path(project), 'issue-details-path' => details_project_error_tracking_index_path(*opts), + 'issue-update-path' => update_project_error_tracking_index_path(*opts), + 'project-issues-path' => project_issues_path(project), 'issue-stack-trace-path' => stack_trace_project_error_tracking_index_path(*opts) } end diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index d683faf6a204293930b15c897470b43e9b2719c6..63f1f24b6114f2c9d955ad1fbc553bc8c902c1db 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -563,6 +563,7 @@ module ProjectsHelper requestAccessEnabled: !!project.request_access_enabled, issuesAccessLevel: feature.issues_access_level, repositoryAccessLevel: feature.repository_access_level, + forkingAccessLevel: feature.forking_access_level, mergeRequestsAccessLevel: feature.merge_requests_access_level, buildsAccessLevel: feature.builds_access_level, wikiAccessLevel: feature.wiki_access_level, @@ -587,6 +588,7 @@ module ProjectsHelper lfsHelpPath: help_page_path('workflow/lfs/manage_large_binaries_with_git_lfs'), pagesAvailable: Gitlab.config.pages.enabled, pagesAccessControlEnabled: Gitlab.config.pages.access_control, + pagesAccessControlForced: ::Gitlab::Pages.access_control_is_forced?, pagesHelpPath: help_page_path('user/project/pages/introduction', anchor: 'gitlab-pages-access-control-core') } end diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb index a89fea4b7b80de2a050f77fc528577ee0e435bff..9a5c5f274a0dc9c809f1c5f44cb3d2812625ee94 100644 --- a/app/helpers/search_helper.rb +++ b/app/helpers/search_helper.rb @@ -143,7 +143,7 @@ module SearchHelper # Autocomplete results for the current project, if it's defined def project_autocomplete - if @project && @project.repository.exists? && @project.repository.root_ref + if @project && @project.repository.root_ref ref = @ref || @project.repository.root_ref [ diff --git a/app/helpers/selects_helper.rb b/app/helpers/selects_helper.rb index 90c54123597430af0447027899c9a243cbb896be..4d0f9e530fb66f571bc8b022b1ec172bf2757127 100644 --- a/app/helpers/selects_helper.rb +++ b/app/helpers/selects_helper.rb @@ -85,7 +85,8 @@ module SelectsHelper first_user: opts[:first_user] && current_user ? current_user.username : false, current_user: opts[:current_user] || false, author_id: opts[:author_id] || '', - skip_users: opts[:skip_users] ? opts[:skip_users].map(&:id) : nil + skip_users: opts[:skip_users] ? opts[:skip_users].map(&:id) : nil, + qa_selector: opts[:qa_selector] || '' } end end diff --git a/app/helpers/snippets_helper.rb b/app/helpers/snippets_helper.rb index 1c7690f30d27169da671e51b5e5d57cf1d84968b..fd7e58826b522c42c4e80a36eced5c8ab515497d 100644 --- a/app/helpers/snippets_helper.rb +++ b/app/helpers/snippets_helper.rb @@ -127,7 +127,7 @@ module SnippetsHelper return unless attrs = snippet_badge_attributes(snippet) css_class, text = attrs - tag.span(class: ['badge', 'badge-gray']) do + tag.span(class: %w[badge badge-gray]) do concat(tag.i(class: ['fa', css_class])) concat(' ') concat(text) diff --git a/app/helpers/user_callouts_helper.rb b/app/helpers/user_callouts_helper.rb index 11b78b8fd59ac4ae9a670049e1ea88b298c92d7a..b3eee25674b24b0f25f555c27d9550c0ff4ce244 100644 --- a/app/helpers/user_callouts_helper.rb +++ b/app/helpers/user_callouts_helper.rb @@ -27,7 +27,7 @@ module UserCalloutsHelper end def show_tabs_feature_highlight? - !user_dismissed?(TABS_POSITION_HIGHLIGHT) && !Rails.env.test? + current_user && !user_dismissed?(TABS_POSITION_HIGHLIGHT) && !Rails.env.test? end private diff --git a/app/mailers/emails/projects.rb b/app/mailers/emails/projects.rb index 6274879ee99dce4120dd57330a1c6d4da75a1f66..5c957437039b30bf95232aa7559c3d6151318332 100644 --- a/app/mailers/emails/projects.rb +++ b/app/mailers/emails/projects.rb @@ -51,7 +51,7 @@ module Emails add_project_headers headers['X-GitLab-Author'] = @message.author_username - mail(from: sender(@message.author_id, @message.send_from_committer_email?), + mail(from: sender(@message.author_id, send_from_user_email: @message.send_from_committer_email?), reply_to: @message.reply_to, subject: @message.subject) end diff --git a/app/mailers/notify.rb b/app/mailers/notify.rb index c7cfefeec9b07be1c60d975faf4e9ed4319106ef..92939136de259a6af2e509b170da7118fdd90fda 100644 --- a/app/mailers/notify.rb +++ b/app/mailers/notify.rb @@ -59,11 +59,11 @@ class Notify < BaseMailer # Return an email address that displays the name of the sender. # Only the displayed name changes; the actual email address is always the same. - def sender(sender_id, send_from_user_email = false) + def sender(sender_id, send_from_user_email: false, sender_name: nil) return unless sender = User.find(sender_id) address = default_sender_address - address.display_name = sender.name + address.display_name = sender_name.presence || sender.name if send_from_user_email && can_send_from_user_email?(sender) address.address = sender.email diff --git a/app/models/active_session.rb b/app/models/active_session.rb index 3ecc31371572646d42eedef0dff85d7c6ecbce20..f37da1b7f59bafbf2c8671454cdb22ae7a8a4f42 100644 --- a/app/models/active_session.rb +++ b/app/models/active_session.rb @@ -6,9 +6,11 @@ class ActiveSession SESSION_BATCH_SIZE = 200 ALLOWED_NUMBER_OF_ACTIVE_SESSIONS = 100 + attr_writer :session_id + attr_accessor :created_at, :updated_at, - :session_id, :ip_address, - :browser, :os, :device_name, :device_type, + :ip_address, :browser, :os, + :device_name, :device_type, :is_impersonated def current?(session) @@ -21,6 +23,11 @@ class ActiveSession device_type&.titleize end + def public_id + encrypted_id = Gitlab::CryptoHelper.aes256_gcm_encrypt(session_id) + CGI.escape(encrypted_id) + end + def self.set(user, request) Gitlab::Redis::SharedState.with do |redis| session_id = request.session.id @@ -70,6 +77,11 @@ class ActiveSession end end + def self.destroy_with_public_id(user, public_id) + session_id = decrypt_public_id(public_id) + destroy(user, session_id) unless session_id.nil? + end + def self.destroy_sessions(redis, user, session_ids) key_names = session_ids.map {|session_id| key_name(user.id, session_id) } session_names = session_ids.map {|session_id| "#{Gitlab::Redis::SharedState::SESSION_NAMESPACE}:#{session_id}" } @@ -146,9 +158,9 @@ class ActiveSession # remove sessions if there are more than ALLOWED_NUMBER_OF_ACTIVE_SESSIONS. sessions = active_session_entries(session_ids, user.id, redis) sessions.sort_by! {|session| session.updated_at }.reverse! - sessions = sessions.drop(ALLOWED_NUMBER_OF_ACTIVE_SESSIONS) - sessions = sessions.map { |session| session.session_id } - destroy_sessions(redis, user, sessions) if sessions.any? + destroyable_sessions = sessions.drop(ALLOWED_NUMBER_OF_ACTIVE_SESSIONS) + destroyable_session_ids = destroyable_sessions.map { |session| session.send :session_id } # rubocop:disable GitlabSecurity/PublicSend + destroy_sessions(redis, user, destroyable_session_ids) if destroyable_session_ids.any? end def self.cleaned_up_lookup_entries(redis, user) @@ -167,4 +179,15 @@ class ActiveSession entries.compact end + + private_class_method def self.decrypt_public_id(public_id) + decoded_id = CGI.unescape(public_id) + Gitlab::CryptoHelper.aes256_gcm_decrypt(decoded_id) + rescue + nil + end + + private + + attr_reader :session_id end diff --git a/app/models/appearance.rb b/app/models/appearance.rb index 2815a117f7f08dbab900371483738ed418f1c409..1104b676bc4827c91c31cbda553b0df7d356a66b 100644 --- a/app/models/appearance.rb +++ b/app/models/appearance.rb @@ -18,6 +18,11 @@ class Appearance < ApplicationRecord validate :single_appearance_row, on: :create + default_value_for :title, '' + default_value_for :description, '' + default_value_for :new_project_guidelines, '' + default_value_for :header_message, '' + default_value_for :footer_message, '' default_value_for :message_background_color, '#E75E40' default_value_for :message_font_color, '#FFFFFF' default_value_for :email_header_and_footer_enabled, false diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index 456b643008851bdb978a7ceaa1926e41e60a70a6..10d15e84b8d8a913be1f293ba657c3bc8ba1354e 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -11,6 +11,7 @@ class ApplicationSetting < ApplicationRecord add_authentication_token_field :static_objects_external_storage_auth_token belongs_to :instance_administration_project, class_name: "Project" + belongs_to :instance_administrators_group, class_name: "Group" # Include here so it can override methods from # `add_authentication_token_field` @@ -121,6 +122,11 @@ class ApplicationSetting < ApplicationRecord presence: true, numericality: { only_integer: true, greater_than: 0 } + validates :max_pages_size, + presence: true, + numericality: { only_integer: true, greater_than: 0, + less_than: ::Gitlab::Pages::MAX_SIZE / 1.megabyte } + validates :default_artifacts_expire_in, presence: true, duration: true validates :container_registry_token_expire_delay, @@ -164,7 +170,11 @@ class ApplicationSetting < ApplicationRecord validates :gitaly_timeout_default, presence: true, - numericality: { only_integer: true, greater_than_or_equal_to: 0 } + numericality: { + only_integer: true, + greater_than_or_equal_to: 0, + less_than_or_equal_to: Settings.gitlab.max_request_duration_seconds + } validates :gitaly_timeout_medium, presence: true, diff --git a/app/models/blob.rb b/app/models/blob.rb index 0a425f2b961513bafc338ae04a449eb2318ecb9d..42ee00bc1967e10734f36fa6c07369c3d233ec12 100644 --- a/app/models/blob.rb +++ b/app/models/blob.rb @@ -51,6 +51,7 @@ class Blob < SimpleDelegator BlobViewer::Contributing, BlobViewer::Changelog, + BlobViewer::CargoToml, BlobViewer::Cartfile, BlobViewer::ComposerJson, BlobViewer::Gemfile, diff --git a/app/models/blob_viewer/cargo_toml.rb b/app/models/blob_viewer/cargo_toml.rb new file mode 100644 index 0000000000000000000000000000000000000000..2f1ebd25b4f5693b80776b2b1ee90dacf1f2bc7f --- /dev/null +++ b/app/models/blob_viewer/cargo_toml.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module BlobViewer + class CargoToml < DependencyManager + include Static + + self.file_types = %i(cargo_toml) + + def manager_name + 'Cargo' + end + + def manager_url + 'https://crates.io/' + end + end +end diff --git a/app/models/board.rb b/app/models/board.rb index f3f938224a4462678dfb2c03d8889f55336482d3..38bbb55004470205f26e8d5846d4e36263df4813 100644 --- a/app/models/board.rb +++ b/app/models/board.rb @@ -11,6 +11,8 @@ class Board < ApplicationRecord validates :group, presence: true, unless: :project scope :with_associations, -> { preload(:destroyable_lists) } + scope :order_by_name_asc, -> { order(arel_table[:name].lower.asc) } + scope :first_board, -> { where(id: self.order_by_name_asc.limit(1).select(:id)) } def project_needed? !group diff --git a/app/models/ci/bridge.rb b/app/models/ci/bridge.rb index 6c51f650b6a1c56206a3ae4b68ac14c0944855fe..e6d41dd277913a3f2b771a7f6ff7d62df6bb938c 100644 --- a/app/models/ci/bridge.rb +++ b/app/models/ci/bridge.rb @@ -1,8 +1,7 @@ # frozen_string_literal: true module Ci - class Bridge < CommitStatus - include Ci::Processable + class Bridge < Ci::Processable include Ci::Contextable include Ci::PipelineDelegator include Importable @@ -54,6 +53,10 @@ module Ci def to_partial_path 'projects/generic_commit_statuses/generic_commit_status' end + + def yaml_for_downstream + nil + end end end diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 7e7c580a48ef88dd83253d47be558bb21797b69f..369a793f3d568d3a9a92662959a83cad8d010dea 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -1,8 +1,7 @@ # frozen_string_literal: true module Ci - class Build < CommitStatus - include Ci::Processable + class Build < Ci::Processable include Ci::Metadatable include Ci::Contextable include Ci::PipelineDelegator @@ -23,6 +22,7 @@ module Ci belongs_to :runner belongs_to :trigger_request belongs_to :erased_by, class_name: 'User' + belongs_to :resource_group, class_name: 'Ci::ResourceGroup', inverse_of: :builds RUNNER_FEATURES = { upload_multiple_artifacts: -> (build) { build.publishes_artifacts_reports? }, @@ -34,6 +34,7 @@ module Ci }.freeze has_one :deployment, as: :deployable, class_name: 'Deployment' + has_one :resource, class_name: 'Ci::Resource', inverse_of: :build has_many :trace_sections, class_name: 'Ci::BuildTraceSection' has_many :trace_chunks, class_name: 'Ci::BuildTraceChunk', foreign_key: :build_id @@ -204,9 +205,25 @@ module Ci state_machine :status do event :enqueue do + transition [:created, :skipped, :manual, :scheduled] => :waiting_for_resource, if: :requires_resource? transition [:created, :skipped, :manual, :scheduled] => :preparing, if: :any_unmet_prerequisites? end + event :enqueue_scheduled do + transition scheduled: :waiting_for_resource, if: :requires_resource? + transition scheduled: :preparing, if: :any_unmet_prerequisites? + transition scheduled: :pending + end + + event :enqueue_waiting_for_resource do + transition waiting_for_resource: :preparing, if: :any_unmet_prerequisites? + transition waiting_for_resource: :pending + end + + event :enqueue_preparing do + transition preparing: :pending + end + event :actionize do transition created: :manual end @@ -219,14 +236,8 @@ module Ci transition scheduled: :manual end - event :enqueue_scheduled do - transition scheduled: :preparing, if: ->(build) do - build.scheduled_at&.past? && build.any_unmet_prerequisites? - end - - transition scheduled: :pending, if: ->(build) do - build.scheduled_at&.past? && !build.any_unmet_prerequisites? - end + before_transition on: :enqueue_scheduled do |build| + build.scheduled_at.nil? || build.scheduled_at.past? # If false is returned, it stops the transition end before_transition scheduled: any do |build| @@ -237,6 +248,27 @@ module Ci build.scheduled_at = build.options_scheduled_at end + before_transition any => :waiting_for_resource do |build| + build.waiting_for_resource_at = Time.now + end + + before_transition on: :enqueue_waiting_for_resource do |build| + next unless build.requires_resource? + + build.resource_group.assign_resource_to(build) # If false is returned, it stops the transition + end + + after_transition any => :waiting_for_resource do |build| + build.run_after_commit do + Ci::ResourceGroups::AssignResourceFromResourceGroupWorker + .perform_async(build.resource_group_id) + end + end + + before_transition on: :enqueue_preparing do |build| + !build.any_unmet_prerequisites? # If false is returned, it stops the transition + end + after_transition created: :scheduled do |build| build.run_after_commit do Ci::BuildScheduleWorker.perform_at(build.scheduled_at, build.id) @@ -265,6 +297,16 @@ module Ci end end + after_transition any => ::Ci::Build.completed_statuses do |build| + next unless build.resource_group_id.present? + next unless build.resource_group.release_resource_from(build) + + build.run_after_commit do + Ci::ResourceGroups::AssignResourceFromResourceGroupWorker + .perform_async(build.resource_group_id) + end + end + after_transition any => [:success, :failed, :canceled] do |build| build.run_after_commit do BuildFinishedWorker.perform_async(id) @@ -405,10 +447,6 @@ module Ci options_retry_when.include?('always') end - def latest? - !retried? - end - def any_unmet_prerequisites? prerequisites.present? end @@ -437,6 +475,11 @@ module Ci end end + def requires_resource? + Feature.enabled?(:ci_resource_group, project, default_enabled: true) && + self.resource_group_id.present? + end + def has_environment? environment.present? end diff --git a/app/models/ci/job_artifact.rb b/app/models/ci/job_artifact.rb index 62bf2c3ac9c544b2e18ef439b8f16ece6d9b3894..9eca324f0fcb68eb2ff42187792400d956e2c738 100644 --- a/app/models/ci/job_artifact.rb +++ b/app/models/ci/job_artifact.rb @@ -16,6 +16,8 @@ module Ci archive: nil, metadata: nil, trace: nil, + metrics_referee: nil, + network_referee: nil, junit: 'junit.xml', codequality: 'gl-code-quality-report.json', sast: 'gl-sast-report.json', @@ -23,6 +25,7 @@ module Ci container_scanning: 'gl-container-scanning-report.json', dast: 'gl-dast-report.json', license_management: 'gl-license-management-report.json', + license_scanning: 'gl-license-scanning-report.json', performance: 'performance.json', metrics: 'metrics.txt' }.freeze @@ -36,6 +39,8 @@ module Ci REPORT_TYPES = { junit: :gzip, metrics: :gzip, + metrics_referee: :gzip, + network_referee: :gzip, # All these file formats use `raw` as we need to store them uncompressed # for Frontend to fetch the files and do analysis @@ -46,6 +51,7 @@ module Ci container_scanning: :raw, dast: :raw, license_management: :raw, + license_scanning: :raw, performance: :raw }.freeze @@ -104,8 +110,11 @@ module Ci dast: 8, ## EE-specific codequality: 9, ## EE-specific license_management: 10, ## EE-specific + license_scanning: 101, ## EE-specific till 13.0 performance: 11, ## EE-specific - metrics: 12 ## EE-specific + metrics: 12, ## EE-specific + metrics_referee: 13, ## runner referees + network_referee: 14 ## runner referees } enum file_format: { diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index 3710a0b914e375fdca74753830e07f577ee3ae65..7e3ba98d86c990d99b7ab4eb8daab2f7af0d8d0b 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -26,15 +26,14 @@ module Ci belongs_to :merge_request, class_name: 'MergeRequest' belongs_to :external_pull_request - has_internal_id :iid, scope: :project, presence: false, ensure_if: -> { !importing? }, init: ->(s) do + has_internal_id :iid, scope: :project, presence: false, track_if: -> { !importing? }, ensure_if: -> { !importing? }, init: ->(s) do s&.project&.all_pipelines&.maximum(:iid) || s&.project&.all_pipelines&.count end has_many :stages, -> { order(position: :asc) }, inverse_of: :pipeline has_many :statuses, class_name: 'CommitStatus', foreign_key: :commit_id, inverse_of: :pipeline has_many :latest_statuses_ordered_by_stage, -> { latest.order(:stage_idx, :stage) }, class_name: 'CommitStatus', foreign_key: :commit_id, inverse_of: :pipeline - has_many :processables, -> { processables }, - class_name: 'CommitStatus', foreign_key: :commit_id, inverse_of: :pipeline + has_many :processables, class_name: 'Ci::Processable', foreign_key: :commit_id, inverse_of: :pipeline 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' @@ -61,9 +60,13 @@ module Ci has_one :chat_data, class_name: 'Ci::PipelineChatData' has_many :triggered_pipelines, through: :sourced_pipelines, source: :pipeline + has_many :child_pipelines, -> { merge(Ci::Sources::Pipeline.same_project) }, through: :sourced_pipelines, source: :pipeline has_one :triggered_by_pipeline, through: :source_pipeline, source: :source_pipeline + has_one :parent_pipeline, -> { merge(Ci::Sources::Pipeline.same_project) }, through: :source_pipeline, source: :source_pipeline has_one :source_job, through: :source_pipeline, source: :source_job + has_one :pipeline_config, class_name: 'Ci::PipelineConfig', inverse_of: :pipeline + accepts_nested_attributes_for :variables, reject_if: :persisted? delegate :id, to: :project, prefix: true @@ -97,10 +100,14 @@ module Ci state_machine :status, initial: :created do event :enqueue do - transition [:created, :preparing, :skipped, :scheduled] => :pending + transition [:created, :waiting_for_resource, :preparing, :skipped, :scheduled] => :pending transition [:success, :failed, :canceled] => :running end + event :request_resource do + transition any - [:waiting_for_resource] => :waiting_for_resource + end + event :prepare do transition any - [:preparing] => :preparing end @@ -137,7 +144,7 @@ module Ci # Do not add any operations to this state_machine # Create a separate worker for each new operation - before_transition [:created, :preparing, :pending] => :running do |pipeline| + before_transition [:created, :waiting_for_resource, :preparing, :pending] => :running do |pipeline| pipeline.started_at = Time.now end @@ -160,7 +167,7 @@ module Ci end end - after_transition [:created, :preparing, :pending] => :running do |pipeline| + after_transition [:created, :waiting_for_resource, :preparing, :pending] => :running do |pipeline| pipeline.run_after_commit { PipelineMetricsWorker.perform_async(pipeline.id) } end @@ -168,7 +175,7 @@ module Ci pipeline.run_after_commit { PipelineMetricsWorker.perform_async(pipeline.id) } end - after_transition [:created, :preparing, :pending, :running] => :success do |pipeline| + after_transition [:created, :waiting_for_resource, :preparing, :pending, :running] => :success do |pipeline| pipeline.run_after_commit { PipelineSuccessWorker.perform_async(pipeline.id) } end @@ -190,6 +197,10 @@ module Ci AutoMergeProcessWorker.perform_async(merge_request.id) end + + if pipeline.auto_devops_source? + self.class.auto_devops_pipelines_completed_total.increment(status: pipeline.status) + end end end @@ -207,6 +218,7 @@ module Ci end scope :internal, -> { where(source: internal_sources) } + scope :no_child, -> { where.not(source: :parent_pipeline) } scope :ci_sources, -> { where(config_source: ::Ci::PipelineEnums.ci_config_sources_values) } scope :for_user, -> (user) { where(user: user) } scope :for_sha, -> (sha) { where(sha: sha) } @@ -319,7 +331,11 @@ module Ci end def self.bridgeable_statuses - ::Ci::Pipeline::AVAILABLE_STATUSES - %w[created preparing pending] + ::Ci::Pipeline::AVAILABLE_STATUSES - %w[created waiting_for_resource preparing pending] + end + + def self.auto_devops_pipelines_completed_total + @auto_devops_pipelines_completed_total ||= Gitlab::Metrics.counter(:auto_devops_pipelines_completed_total, 'Number of completed auto devops pipelines') end def stages_count @@ -499,11 +515,9 @@ module Ci # rubocop: enable CodeReuse/ServiceClass def mark_as_processable_after_stage(stage_idx) - builds.skipped.after_stage(stage_idx).find_each(&:process) - end - - def child? - false + builds.skipped.after_stage(stage_idx).find_each do |build| + Gitlab::OptimisticLocking.retry_lock(build, &:process) + end end def latest? @@ -542,6 +556,13 @@ module Ci end end + def needs_processing? + statuses + .where(processed: [false, nil]) + .latest + .exists? + end + # TODO: this logic is duplicate with Pipeline::Chain::Config::Content # we should persist this is `ci_pipelines.config_path` def config_path @@ -571,11 +592,11 @@ module Ci project.notes.for_commit_id(sha) end - def update_status + def set_status(new_status) retry_optimistic_lock(self) do - new_status = latest_builds_status.to_s case new_status when 'created' then nil + when 'waiting_for_resource' then request_resource when 'preparing' then prepare when 'pending' then enqueue when 'running' then run @@ -592,6 +613,10 @@ module Ci end end + def update_legacy_status + set_status(latest_builds_status.to_s) + end + def protected_ref? strong_memoize(:protected_ref) { project.protected_for?(git_ref) } end @@ -687,6 +712,24 @@ module Ci all_merge_requests.order(id: :desc) end + # If pipeline is a child of another pipeline, include the parent + # and the siblings, otherwise return only itself. + def same_family_pipeline_ids + if (parent = parent_pipeline) + [parent.id] + parent.child_pipelines.pluck(:id) + else + [self.id] + end + end + + def child? + parent_pipeline.present? + end + + def parent? + child_pipelines.exists? + end + def detailed_status(current_user) Gitlab::Ci::Status::Pipeline::Factory .new(self, current_user) diff --git a/app/models/ci/pipeline_config.rb b/app/models/ci/pipeline_config.rb new file mode 100644 index 0000000000000000000000000000000000000000..d5a8da2bc1e042d28f1a096ee438b2bd80077192 --- /dev/null +++ b/app/models/ci/pipeline_config.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Ci + class PipelineConfig < ApplicationRecord + extend Gitlab::Ci::Model + + self.table_name = 'ci_pipelines_config' + self.primary_key = :pipeline_id + + belongs_to :pipeline, class_name: "Ci::Pipeline", inverse_of: :pipeline_config + validates :pipeline, presence: true + validates :content, presence: true + end +end diff --git a/app/models/ci/pipeline_enums.rb b/app/models/ci/pipeline_enums.rb index 3cd888079694fadda8c7fd0307a8cb94a38627d8..fde169d2f0330a1177db0e186dff256e386519f2 100644 --- a/app/models/ci/pipeline_enums.rb +++ b/app/models/ci/pipeline_enums.rb @@ -23,10 +23,13 @@ module Ci schedule: 4, api: 5, external: 6, + # TODO: Rename `pipeline` to `cross_project_pipeline` in 13.0 + # https://gitlab.com/gitlab-org/gitlab/issues/195991 pipeline: 7, chat: 8, merge_request_event: 10, - external_pull_request_event: 11 + external_pull_request_event: 11, + parent_pipeline: 12 } end @@ -38,7 +41,8 @@ module Ci repository_source: 1, auto_devops_source: 2, remote_source: 4, - external_project_source: 5 + external_project_source: 5, + bridge_source: 6 } end diff --git a/app/models/ci/pipeline_schedule.rb b/app/models/ci/pipeline_schedule.rb index 946241b7d4c3c3936378bed43a418dba1be34534..9a1445e624ca82e3d23e4bc82524ac298977c274 100644 --- a/app/models/ci/pipeline_schedule.rb +++ b/app/models/ci/pipeline_schedule.rb @@ -5,6 +5,7 @@ module Ci extend Gitlab::Ci::Model include Importable include StripAttribute + include Schedulable belongs_to :project belongs_to :owner, class_name: 'User' @@ -18,13 +19,10 @@ module Ci validates :description, presence: true validates :variables, variable_duplicates: true - before_save :set_next_run_at - strip_attributes :cron scope :active, -> { where(active: true) } scope :inactive, -> { where(active: false) } - scope :runnable_schedules, -> { active.where("next_run_at < ?", Time.now) } scope :preloaded, -> { preload(:owner, :project) } accepts_nested_attributes_for :variables, allow_destroy: true @@ -62,12 +60,6 @@ module Ci end end - def schedule_next_run! - save! # with set_next_run_at - rescue ActiveRecord::RecordInvalid - update_column(:next_run_at, nil) # update without validation - end - def job_variables variables&.map(&:to_runner_variable) || [] end diff --git a/app/models/ci/processable.rb b/app/models/ci/processable.rb new file mode 100644 index 0000000000000000000000000000000000000000..6c4b271cd2c4b9d9c2fe0c96ff3635ff0ffe2b6c --- /dev/null +++ b/app/models/ci/processable.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +module Ci + class Processable < ::CommitStatus + has_many :needs, class_name: 'Ci::BuildNeed', foreign_key: :build_id, inverse_of: :build + + accepts_nested_attributes_for :needs + + scope :preload_needs, -> { preload(:needs) } + + def self.select_with_aggregated_needs(project) + return all unless Feature.enabled?(:ci_dag_support, project, default_enabled: true) + + aggregated_needs_names = Ci::BuildNeed + .scoped_build + .select("ARRAY_AGG(name)") + .to_sql + + all.select( + '*', + "(#{aggregated_needs_names}) as aggregated_needs_names" + ) + end + + validates :type, presence: true + + def aggregated_needs_names + read_attribute(:aggregated_needs_names) + end + + def schedulable? + raise NotImplementedError + end + + def action? + raise NotImplementedError + end + + def when + read_attribute(:when) || 'on_success' + end + + def expanded_environment_name + raise NotImplementedError + end + + def scoped_variables_hash + raise NotImplementedError + end + end +end diff --git a/app/models/ci/resource.rb b/app/models/ci/resource.rb new file mode 100644 index 0000000000000000000000000000000000000000..ee5b6546165bb47c4adde08a31468a0b771ed305 --- /dev/null +++ b/app/models/ci/resource.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Ci + class Resource < ApplicationRecord + extend Gitlab::Ci::Model + + belongs_to :resource_group, class_name: 'Ci::ResourceGroup', inverse_of: :resources + belongs_to :build, class_name: 'Ci::Build', inverse_of: :resource + + scope :free, -> { where(build: nil) } + scope :retained_by, -> (build) { where(build: build) } + end +end diff --git a/app/models/ci/resource_group.rb b/app/models/ci/resource_group.rb new file mode 100644 index 0000000000000000000000000000000000000000..eb18f3da0bf2fb4d68426e4a7bef54925a6dacc0 --- /dev/null +++ b/app/models/ci/resource_group.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module Ci + class ResourceGroup < ApplicationRecord + extend Gitlab::Ci::Model + + belongs_to :project, inverse_of: :resource_groups + + has_many :resources, class_name: 'Ci::Resource', inverse_of: :resource_group + has_many :builds, class_name: 'Ci::Build', inverse_of: :resource_group + + validates :key, + length: { maximum: 255 }, + format: { with: Gitlab::Regex.environment_name_regex, + message: Gitlab::Regex.environment_name_regex_message } + + before_create :ensure_resource + + ## + # NOTE: This is concurrency-safe method that the subquery in the `UPDATE` + # works as explicit locking. + def assign_resource_to(build) + resources.free.limit(1).update_all(build_id: build.id) > 0 + end + + def release_resource_from(build) + resources.retained_by(build).update_all(build_id: nil) > 0 + end + + private + + def ensure_resource + # Currently we only support one resource per group, which means + # maximum one build can be set to the resource group, thus builds + # belong to the same resource group are executed once at time. + self.resources.build if self.resources.empty? + end + end +end diff --git a/app/models/ci/sources/pipeline.rb b/app/models/ci/sources/pipeline.rb index feaec27281c9ac65485cb768856b55c6cdee1a25..d71e3b55b9a87e70a9301e5faf979ea9f65d8bad 100644 --- a/app/models/ci/sources/pipeline.rb +++ b/app/models/ci/sources/pipeline.rb @@ -18,6 +18,8 @@ module Ci validates :source_project, presence: true validates :source_job, presence: true validates :source_pipeline, presence: true + + scope :same_project, -> { where(arel_table[:source_project_id].eq(arel_table[:project_id])) } end end end diff --git a/app/models/ci/stage.rb b/app/models/ci/stage.rb index 77ac8bfe87579776f0dec4f80cb1e46b8e2887ce..75f73429c2a410f69b2be1746c29f6e541b8ccc5 100644 --- a/app/models/ci/stage.rb +++ b/app/models/ci/stage.rb @@ -13,9 +13,12 @@ module Ci belongs_to :pipeline has_many :statuses, class_name: 'CommitStatus', foreign_key: :stage_id + has_many :processables, class_name: 'Ci::Processable', foreign_key: :stage_id has_many :builds, foreign_key: :stage_id has_many :bridges, foreign_key: :stage_id + scope :ordered, -> { order(position: :asc) } + with_options unless: :importing? do validates :project, presence: true validates :pipeline, presence: true @@ -39,10 +42,14 @@ module Ci state_machine :status, initial: :created do event :enqueue do - transition [:created, :preparing] => :pending + transition [:created, :waiting_for_resource, :preparing] => :pending transition [:success, :failed, :canceled, :skipped] => :running end + event :request_resource do + transition any - [:waiting_for_resource] => :waiting_for_resource + end + event :prepare do transition any - [:preparing] => :preparing end @@ -76,11 +83,11 @@ module Ci end end - def update_status + def set_status(new_status) retry_optimistic_lock(self) do - new_status = latest_stage_status.to_s case new_status when 'created' then nil + when 'waiting_for_resource' then request_resource when 'preparing' then prepare when 'pending' then enqueue when 'running' then run @@ -97,6 +104,10 @@ module Ci end end + def update_legacy_status + set_status(latest_stage_status.to_s) + end + def groups @groups ||= Ci::Group.fabricate(self) end diff --git a/app/models/ci/trigger.rb b/app/models/ci/trigger.rb index 68548bd2fdc33dcd87bfedabd5a124a4243d5b41..85cb3f5b46a360c84ed01ef18a501569a2dab732 100644 --- a/app/models/ci/trigger.rb +++ b/app/models/ci/trigger.rb @@ -11,7 +11,7 @@ module Ci has_many :trigger_requests validates :token, presence: true, uniqueness: true - validates :owner, presence: true, unless: :supports_legacy_tokens? + validates :owner, presence: true before_validation :set_default_values @@ -31,17 +31,8 @@ module Ci token[0...4] if token.present? end - def legacy? - self.owner_id.blank? - end - - def supports_legacy_tokens? - Feature.enabled?(:use_legacy_pipeline_triggers, project) - end - def can_access_project? - supports_legacy_tokens? && legacy? || - Ability.allowed?(self.owner, :create_build, project) + Ability.allowed?(self.owner, :create_build, project) end end end diff --git a/app/models/clusters/applications/elastic_stack.rb b/app/models/clusters/applications/elastic_stack.rb index 9854ad2ea3ea40988db1cb2ba7064d79f0c9dbab..e86a4597ed892126b627aea0490944a06b1db3e3 100644 --- a/app/models/clusters/applications/elastic_stack.rb +++ b/app/models/clusters/applications/elastic_stack.rb @@ -15,24 +15,15 @@ module Clusters include ::Clusters::Concerns::ApplicationData include ::Gitlab::Utils::StrongMemoize - default_value_for :version, VERSION - - def set_initial_status - return unless not_installable? - return unless cluster&.application_ingress_available? + include IgnorableColumns + ignore_column :kibana_hostname, remove_with: '12.8', remove_after: '2020-01-22' - ingress = cluster.application_ingress - self.status = status_states[:installable] if ingress.external_ip_or_hostname? - end + default_value_for :version, VERSION def chart 'stable/elastic-stack' end - def values - content_values.to_yaml - end - def install_command Gitlab::Kubernetes::Helm::InstallCommand.new( name: 'elastic-stack', @@ -78,24 +69,6 @@ module Clusters private - def specification - { - "kibana" => { - "ingress" => { - "hosts" => [kibana_hostname], - "tls" => [{ - "hosts" => [kibana_hostname], - "secretName" => "kibana-cert" - }] - } - } - } - end - - def content_values - YAML.load_file(chart_values_file).deep_merge!(specification) - end - def post_delete_script [ Gitlab::Kubernetes::KubectlCmd.delete("pvc", "--selector", "release=elastic-stack") diff --git a/app/models/clusters/applications/ingress.rb b/app/models/clusters/applications/ingress.rb index d140649af3c10872639ddb2723227f43ba14c4ec..63f216c7af572ae423c0c28259f7fbcc3659a13f 100644 --- a/app/models/clusters/applications/ingress.rb +++ b/app/models/clusters/applications/ingress.rb @@ -14,6 +14,7 @@ module Clusters include AfterCommitQueue default_value_for :ingress_type, :nginx + default_value_for :modsecurity_enabled, false default_value_for :version, VERSION enum ingress_type: { @@ -41,7 +42,7 @@ module Clusters end def allowed_to_uninstall? - external_ip_or_hostname? && application_jupyter_nil_or_installable? && application_elastic_stack_nil_or_installable? + external_ip_or_hostname? && application_jupyter_nil_or_installable? end def install_command @@ -73,7 +74,7 @@ module Clusters private def specification - return {} unless Feature.enabled?(:ingress_modsecurity) + return {} unless modsecurity_enabled { "controller" => { @@ -154,10 +155,6 @@ module Clusters def application_jupyter_nil_or_installable? cluster.application_jupyter.nil? || cluster.application_jupyter&.installable? end - - def application_elastic_stack_nil_or_installable? - cluster.application_elastic_stack.nil? || cluster.application_elastic_stack&.installable? - end end end end diff --git a/app/models/clusters/applications/jupyter.rb b/app/models/clusters/applications/jupyter.rb index ca93bc15be08cd4ee5c1dac68ad367cfee579791..42fa4a6f179a61a1f79cf594f2797768c4a2d7d3 100644 --- a/app/models/clusters/applications/jupyter.rb +++ b/app/models/clusters/applications/jupyter.rb @@ -5,7 +5,7 @@ require 'securerandom' module Clusters module Applications class Jupyter < ApplicationRecord - VERSION = '0.9-174bbd5' + VERSION = '0.9.0-beta.2' self.table_name = 'clusters_applications_jupyter' diff --git a/app/models/clusters/applications/prometheus.rb b/app/models/clusters/applications/prometheus.rb index 4ac33d4e3bebce05510c674209910455c49b1a33..d24a298b0a649c918a89b28da90fbd337a264af8 100644 --- a/app/models/clusters/applications/prometheus.rb +++ b/app/models/clusters/applications/prometheus.rb @@ -5,7 +5,7 @@ module Clusters class Prometheus < ApplicationRecord include PrometheusAdapter - VERSION = '6.7.3' + VERSION = '9.5.2' self.table_name = 'clusters_applications_prometheus' @@ -90,7 +90,7 @@ module Clusters # ensures headers containing auth data are appended to original k8s client options options = kube_client.rest_client.options.merge(headers: kube_client.headers) Gitlab::PrometheusClient.new(proxy_url, options) - rescue Kubeclient::HttpError, Errno::ECONNRESET, Errno::ECONNREFUSED + rescue Kubeclient::HttpError, Errno::ECONNRESET, Errno::ECONNREFUSED, Errno::ENETUNREACH # If users have mistakenly set parameters or removed the depended clusters, # `proxy_url` could raise an exception because gitlab can not communicate with the cluster. # Since `PrometheusAdapter#can_query?` is eargely loaded on environement pages in gitlab, diff --git a/app/models/clusters/applications/runner.rb b/app/models/clusters/applications/runner.rb index fd05fd6bab975bb5c9272b5745fe62287b3b32a6..a908ca28188dd63bf8a36c922c84888268b8aeb3 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 < ApplicationRecord - VERSION = '0.11.0' + VERSION = '0.12.0' self.table_name = 'clusters_applications_runners' diff --git a/app/models/clusters/concerns/application_core.rb b/app/models/clusters/concerns/application_core.rb index f6431f5bac3943378b890cb2d3ea03a03e835e39..b94f2b158464b6111f306b9fb2ff1fdcb246ac7e 100644 --- a/app/models/clusters/concerns/application_core.rb +++ b/app/models/clusters/concerns/application_core.rb @@ -15,7 +15,7 @@ module Clusters def set_initial_status return unless not_installable? - self.status = status_states[:installable] if cluster&.application_helm_available? + self.status = status_states[:installable] if cluster&.application_helm_available? || Feature.enabled?(:managed_apps_local_tiller) end def can_uninstall? diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb index 8d38835fb3bf49857a231aaae3754ea4baf6979d..f9101609f89f03dcc94dafb78fa77d3881b2c796 100644 --- a/app/models/commit_status.rb +++ b/app/models/commit_status.rb @@ -40,12 +40,12 @@ class CommitStatus < ApplicationRecord scope :latest, -> { where(retried: [false, nil]) } scope :retried, -> { where(retried: true) } scope :ordered, -> { order(:name) } + scope :ordered_by_stage, -> { order(stage_idx: :asc) } scope :latest_ordered, -> { latest.ordered.includes(project: :namespace) } scope :retried_ordered, -> { retried.ordered.includes(project: :namespace) } scope :before_stage, -> (index) { where('stage_idx < ?', index) } scope :for_stage, -> (index) { where(stage_idx: index) } scope :after_stage, -> (index) { where('stage_idx > ?', index) } - scope :processables, -> { where(type: %w[Ci::Build Ci::Bridge]) } scope :for_ids, -> (ids) { where(id: ids) } scope :for_ref, -> (ref) { where(ref: ref) } scope :by_name, -> (name) { where(name: name) } @@ -58,6 +58,10 @@ class CommitStatus < ApplicationRecord preload(:project, :user) end + scope :with_project_preload, -> do + preload(project: :namespace) + end + scope :with_needs, -> (names = nil) do needs = Ci::BuildNeed.scoped_build.select(1) needs = needs.where(name: names) if names @@ -70,6 +74,15 @@ class CommitStatus < ApplicationRecord where('NOT EXISTS (?)', needs) end + scope :match_id_and_lock_version, -> (slice) do + # it expects that items are an array of attributes to match + # each hash needs to have `id` and `lock_version` + slice.inject(self) do |relation, item| + match = CommitStatus.where(item.slice(:id, :lock_version)) + relation.or(match) + end + end + # We use `CommitStatusEnums.failure_reasons` here so that EE can more easily # extend this `Hash` with new values. enum_with_nil failure_reason: ::CommitStatusEnums.failure_reasons @@ -87,6 +100,16 @@ class CommitStatus < ApplicationRecord # rubocop: enable CodeReuse/ServiceClass end + before_save if: :status_changed?, unless: :importing? do + if Feature.disabled?(:ci_atomic_processing, project) + self.processed = nil + elsif latest? + self.processed = false # force refresh of all dependent ones + elsif retried? + self.processed = true # retried are considered to be already processed + end + end + state_machine :status do event :process do transition [:skipped, :manual] => :created @@ -96,7 +119,7 @@ class CommitStatus < ApplicationRecord # A CommitStatus will never have prerequisites, but this event # is shared by Ci::Build, which cannot progress unless prerequisites # are satisfied. - transition [:created, :preparing, :skipped, :manual, :scheduled] => :pending, unless: :any_unmet_prerequisites? + transition [:created, :skipped, :manual, :scheduled] => :pending, if: :all_met_to_become_pending? end event :run do @@ -104,22 +127,22 @@ class CommitStatus < ApplicationRecord end event :skip do - transition [:created, :preparing, :pending] => :skipped + transition [:created, :waiting_for_resource, :preparing, :pending] => :skipped end event :drop do - transition [:created, :preparing, :pending, :running, :scheduled] => :failed + transition [:created, :waiting_for_resource, :preparing, :pending, :running, :scheduled] => :failed end event :success do - transition [:created, :preparing, :pending, :running] => :success + transition [:created, :waiting_for_resource, :preparing, :pending, :running] => :success end event :cancel do - transition [:created, :preparing, :pending, :running, :manual, :scheduled] => :canceled + transition [:created, :waiting_for_resource, :preparing, :pending, :running, :manual, :scheduled] => :canceled end - before_transition [:created, :preparing, :skipped, :manual, :scheduled] => :pending do |commit_status| + before_transition [:created, :waiting_for_resource, :preparing, :skipped, :manual, :scheduled] => :pending do |commit_status| commit_status.queued_at = Time.now end @@ -137,19 +160,13 @@ class CommitStatus < ApplicationRecord end after_transition do |commit_status, transition| - next unless commit_status.project next if transition.loopback? + next if commit_status.processed? + next unless commit_status.project commit_status.run_after_commit do - if pipeline_id - if complete? || manual? - PipelineProcessWorker.perform_async(pipeline_id, [id]) - else - PipelineUpdateWorker.perform_async(pipeline_id) - end - end - - StageUpdateWorker.perform_async(stage_id) + schedule_stage_and_pipeline_update + ExpireJobCacheWorker.perform_async(id) end end @@ -178,6 +195,11 @@ class CommitStatus < ApplicationRecord where(name: names).latest.slow_composite_status || 'success' end + def self.update_as_processed! + # Marks items as processed, and increases `lock_version` (Optimisitc Locking) + update_all('processed=TRUE, lock_version=COALESCE(lock_version,0)+1') + end + def locking_enabled? will_save_change_to_status? end @@ -194,6 +216,10 @@ class CommitStatus < ApplicationRecord calculate_duration end + def latest? + !retried? + end + def playable? false end @@ -218,10 +244,18 @@ class CommitStatus < ApplicationRecord false end + def all_met_to_become_pending? + !any_unmet_prerequisites? && !requires_resource? + end + def any_unmet_prerequisites? false end + def requires_resource? + false + end + def auto_canceled? canceled? && auto_canceled_by_id? end @@ -237,4 +271,21 @@ class CommitStatus < ApplicationRecord v =~ /\d+/ ? v.to_i : v end end + + private + + def schedule_stage_and_pipeline_update + if Feature.enabled?(:ci_atomic_processing, project) + # Atomic Processing requires only single Worker + PipelineProcessWorker.perform_async(pipeline_id, [id]) + else + if complete? || manual? + PipelineProcessWorker.perform_async(pipeline_id, [id]) + else + PipelineUpdateWorker.perform_async(pipeline_id) + end + + StageUpdateWorker.perform_async(stage_id) + end + end end diff --git a/app/models/concerns/atomic_internal_id.rb b/app/models/concerns/atomic_internal_id.rb index 64df265dc25303ea8bd2df2b3155571353c41f84..3e9b084e784a67ccd436eb2d30f7edf28342554f 100644 --- a/app/models/concerns/atomic_internal_id.rb +++ b/app/models/concerns/atomic_internal_id.rb @@ -27,13 +27,13 @@ module AtomicInternalId extend ActiveSupport::Concern class_methods do - def has_internal_id(column, scope:, init:, ensure_if: nil, presence: true) # rubocop:disable Naming/PredicateName + def has_internal_id(column, scope:, init:, ensure_if: nil, track_if: nil, presence: true) # rubocop:disable Naming/PredicateName # We require init here to retain the ability to recalculate in the absence of a - # InternaLId record (we may delete records in `internal_ids` for example). + # InternalId record (we may delete records in `internal_ids` for example). raise "has_internal_id requires a init block, none given." unless init raise "has_internal_id needs to be defined on association." unless self.reflect_on_association(scope) - before_validation :"track_#{scope}_#{column}!", on: :create + before_validation :"track_#{scope}_#{column}!", on: :create, if: track_if before_validation :"ensure_#{scope}_#{column}!", on: :create, if: ensure_if validates column, presence: presence diff --git a/app/models/concerns/awardable.rb b/app/models/concerns/awardable.rb index f229b42ade6acf2d47b68bec9704391ef111f0bb..0f2a389f0a3f5c689279dbe8bf5f2c1d46d5076e 100644 --- a/app/models/concerns/awardable.rb +++ b/app/models/concerns/awardable.rb @@ -67,7 +67,9 @@ module Awardable ) ).join_sources - joins(join_clause).group(awardable_table[:id]).reorder("COUNT(award_emoji.id) #{direction}") + joins(join_clause).group(awardable_table[:id]).reorder( + Arel.sql("COUNT(award_emoji.id) #{direction}") + ) end end diff --git a/app/models/concerns/ci/processable.rb b/app/models/concerns/ci/processable.rb deleted file mode 100644 index c229358ad17d484b1bfeb4383f5e44d565506325..0000000000000000000000000000000000000000 --- a/app/models/concerns/ci/processable.rb +++ /dev/null @@ -1,41 +0,0 @@ -# frozen_string_literal: true - -module Ci - ## - # This module implements methods that need to be implemented by CI/CD - # entities that are supposed to go through pipeline processing - # services. - # - # - module Processable - extend ActiveSupport::Concern - - included do - has_many :needs, class_name: 'Ci::BuildNeed', foreign_key: :build_id, inverse_of: :build - - accepts_nested_attributes_for :needs - - scope :preload_needs, -> { preload(:needs) } - end - - def schedulable? - raise NotImplementedError - end - - def action? - raise NotImplementedError - end - - def when - read_attribute(:when) || 'on_success' - end - - def expanded_environment_name - raise NotImplementedError - end - - def scoped_variables_hash - raise NotImplementedError - end - end -end diff --git a/app/models/concerns/has_status.rb b/app/models/concerns/has_status.rb index c01fb4740e5f0c0597f24c056648f9a641bb0888..e06dad38c3291aa86f7a51ce91c6e82fd950d8ff 100644 --- a/app/models/concerns/has_status.rb +++ b/app/models/concerns/has_status.rb @@ -5,16 +5,16 @@ module HasStatus DEFAULT_STATUS = 'created' BLOCKED_STATUS = %w[manual scheduled].freeze - AVAILABLE_STATUSES = %w[created preparing pending running success failed canceled skipped manual scheduled].freeze + AVAILABLE_STATUSES = %w[created waiting_for_resource preparing pending running success failed canceled skipped manual scheduled].freeze STARTED_STATUSES = %w[running success failed skipped manual scheduled].freeze - ACTIVE_STATUSES = %w[preparing pending running].freeze + ACTIVE_STATUSES = %w[waiting_for_resource preparing pending running].freeze COMPLETED_STATUSES = %w[success failed canceled skipped].freeze - ORDERED_STATUSES = %w[failed preparing pending running manual scheduled canceled success skipped created].freeze + ORDERED_STATUSES = %w[failed preparing pending running waiting_for_resource manual scheduled canceled success skipped created].freeze PASSED_WITH_WARNINGS_STATUSES = %w[failed canceled].to_set.freeze EXCLUDE_IGNORED_STATUSES = %w[manual failed canceled].to_set.freeze STATUSES_ENUM = { created: 0, pending: 1, running: 2, success: 3, failed: 4, canceled: 5, skipped: 6, manual: 7, - scheduled: 8, preparing: 9 }.freeze + scheduled: 8, preparing: 9, waiting_for_resource: 10 }.freeze UnknownStatusError = Class.new(StandardError) @@ -29,6 +29,7 @@ module HasStatus manual = scope_relevant.manual.select('count(*)').to_sql scheduled = scope_relevant.scheduled.select('count(*)').to_sql preparing = scope_relevant.preparing.select('count(*)').to_sql + waiting_for_resource = scope_relevant.waiting_for_resource.select('count(*)').to_sql pending = scope_relevant.pending.select('count(*)').to_sql running = scope_relevant.running.select('count(*)').to_sql skipped = scope_relevant.skipped.select('count(*)').to_sql @@ -46,6 +47,7 @@ module HasStatus WHEN (#{builds})=(#{success})+(#{skipped})+(#{canceled}) THEN 'canceled' WHEN (#{builds})=(#{created})+(#{skipped})+(#{pending}) THEN 'pending' WHEN (#{running})+(#{pending})>0 THEN 'running' + WHEN (#{waiting_for_resource})>0 THEN 'waiting_for_resource' WHEN (#{manual})>0 THEN 'manual' WHEN (#{scheduled})>0 THEN 'scheduled' WHEN (#{preparing})>0 THEN 'preparing' @@ -95,6 +97,7 @@ module HasStatus state_machine :status, initial: :created do state :created, value: 'created' + state :waiting_for_resource, value: 'waiting_for_resource' state :preparing, value: 'preparing' state :pending, value: 'pending' state :running, value: 'running' @@ -107,6 +110,7 @@ module HasStatus end scope :created, -> { with_status(:created) } + scope :waiting_for_resource, -> { with_status(:waiting_for_resource) } scope :preparing, -> { with_status(:preparing) } scope :relevant, -> { without_status(:created) } scope :running, -> { with_status(:running) } @@ -117,8 +121,8 @@ module HasStatus scope :skipped, -> { with_status(:skipped) } scope :manual, -> { with_status(:manual) } scope :scheduled, -> { with_status(:scheduled) } - scope :alive, -> { with_status(:created, :preparing, :pending, :running) } - scope :alive_or_scheduled, -> { with_status(:created, :preparing, :pending, :running, :scheduled) } + scope :alive, -> { with_status(:created, :waiting_for_resource, :preparing, :pending, :running) } + scope :alive_or_scheduled, -> { with_status(:created, :waiting_for_resource, :preparing, :pending, :running, :scheduled) } scope :created_or_pending, -> { with_status(:created, :pending) } scope :running_or_pending, -> { with_status(:running, :pending) } scope :finished, -> { with_status(:success, :failed, :canceled) } @@ -126,7 +130,7 @@ module HasStatus scope :incomplete, -> { without_statuses(completed_statuses) } scope :cancelable, -> do - where(status: [:running, :preparing, :pending, :created, :scheduled]) + where(status: [:running, :waiting_for_resource, :preparing, :pending, :created, :scheduled]) end scope :without_statuses, -> (names) do diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index 9e3fba139e3982152aea73644af2ec4e1580396b..fe0fad4b9d54d719ceff1e211ff46509b13c508f 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -13,6 +13,7 @@ module Issuable include CacheMarkdownField include Participable include Mentionable + include Milestoneable include Subscribable include StripAttribute include Awardable @@ -56,7 +57,6 @@ module Issuable belongs_to :author, class_name: 'User' belongs_to :updated_by, class_name: 'User' belongs_to :last_edited_by, class_name: 'User' - belongs_to :milestone has_many :notes, as: :noteable, inverse_of: :noteable, dependent: :destroy do # rubocop:disable Cop/ActiveRecordDependent def authors_loaded? @@ -89,18 +89,12 @@ module Issuable # to avoid breaking the existing Issuables which may have their descriptions longer validates :description, length: { maximum: DESCRIPTION_LENGTH_MAX }, allow_blank: true, on: :create validate :description_max_length_for_new_records_is_valid, on: :update - validate :milestone_is_valid before_validation :truncate_description_on_import! scope :authored, ->(user) { where(author_id: user) } scope :recent, -> { reorder(id: :desc) } scope :of_projects, ->(ids) { where(project_id: ids) } - scope :of_milestones, ->(ids) { where(milestone_id: ids) } - scope :any_milestone, -> { where('milestone_id IS NOT NULL') } - scope :with_milestone, ->(title) { left_joins_milestones.where(milestones: { title: title }) } - scope :any_release, -> { joins_milestone_releases } - scope :with_release, -> (tag, project_id) { joins_milestone_releases.where( milestones: { releases: { tag: tag, project_id: project_id } } ) } scope :opened, -> { with_state(:opened) } scope :only_opened, -> { with_state(:opened) } scope :closed, -> { with_state(:closed) } @@ -118,20 +112,6 @@ module Issuable end # rubocop:enable GitlabSecurity/SqlInjection - scope :left_joins_milestones, -> { joins("LEFT OUTER JOIN milestones ON #{table_name}.milestone_id = milestones.id") } - scope :order_milestone_due_desc, -> { left_joins_milestones.reorder(Arel.sql('milestones.due_date IS NULL, milestones.id IS NULL, milestones.due_date DESC')) } - scope :order_milestone_due_asc, -> { left_joins_milestones.reorder(Arel.sql('milestones.due_date IS NULL, milestones.id IS NULL, milestones.due_date ASC')) } - - scope :without_release, -> do - joins("LEFT OUTER JOIN milestone_releases ON #{table_name}.milestone_id = milestone_releases.milestone_id") - .where('milestone_releases.release_id IS NULL') - end - - scope :joins_milestone_releases, -> do - joins("JOIN milestone_releases ON #{table_name}.milestone_id = milestone_releases.milestone_id - JOIN releases ON milestone_releases.release_id = releases.id").distinct - end - scope :without_label, -> { joins("LEFT OUTER JOIN label_links ON label_links.target_type = '#{name}' AND label_links.target_id = #{table_name}.id").where(label_links: { id: nil }) } scope :any_label, -> { joins(:label_links).group(:id) } scope :join_project, -> { joins(:project) } @@ -164,10 +144,6 @@ module Issuable private - def milestone_is_valid - errors.add(:milestone_id, message: "is invalid") if respond_to?(:milestone_id) && milestone_id.present? && !milestone_available? - end - def description_max_length_for_new_records_is_valid if new_record? && description.length > Issuable::DESCRIPTION_LENGTH_MAX errors.add(:description, :too_long, count: Issuable::DESCRIPTION_LENGTH_MAX) @@ -332,10 +308,6 @@ module Issuable project end - def milestone_available? - project_id == milestone&.project_id || project.ancestors_upto.compact.include?(milestone&.group) - end - def assignee_or_author?(user) author_id == user.id || assignees.exists?(user.id) end @@ -482,13 +454,6 @@ module Issuable def wipless_title_changed(old_title) old_title != title end - - ## - # Overridden on EE module - # - def supports_milestone? - respond_to?(:milestone_id) - end end Issuable.prepend_if_ee('EE::Issuable') # rubocop: disable Cop/InjectEnterpriseEditionModule diff --git a/app/models/concerns/milestoneable.rb b/app/models/concerns/milestoneable.rb new file mode 100644 index 0000000000000000000000000000000000000000..7fb3f95bf0a5c0eac2de3cd112cd5a1cb6ba6841 --- /dev/null +++ b/app/models/concerns/milestoneable.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +# == Milestoneable concern +# +# Contains functionality related to objects that can be assigned Milestones +# +# Used by Issuable +# +module Milestoneable + extend ActiveSupport::Concern + + included do + belongs_to :milestone + + validate :milestone_is_valid + + after_save :write_to_new_milestone_relationship + + scope :of_milestones, ->(ids) { where(milestone_id: ids) } + scope :any_milestone, -> { where('milestone_id IS NOT NULL') } + scope :with_milestone, ->(title) { left_joins_milestones.where(milestones: { title: title }) } + scope :any_release, -> { joins_milestone_releases } + scope :with_release, -> (tag, project_id) { joins_milestone_releases.where( milestones: { releases: { tag: tag, project_id: project_id } } ) } + + scope :left_joins_milestones, -> { joins("LEFT OUTER JOIN milestones ON #{table_name}.milestone_id = milestones.id") } + scope :order_milestone_due_desc, -> { left_joins_milestones.reorder(Arel.sql('milestones.due_date IS NULL, milestones.id IS NULL, milestones.due_date DESC')) } + scope :order_milestone_due_asc, -> { left_joins_milestones.reorder(Arel.sql('milestones.due_date IS NULL, milestones.id IS NULL, milestones.due_date ASC')) } + + scope :without_release, -> do + joins("LEFT OUTER JOIN milestone_releases ON #{table_name}.milestone_id = milestone_releases.milestone_id") + .where('milestone_releases.release_id IS NULL') + end + + scope :joins_milestone_releases, -> do + joins("JOIN milestone_releases ON #{table_name}.milestone_id = milestone_releases.milestone_id + JOIN releases ON milestone_releases.release_id = releases.id").distinct + end + + private + + def milestone_is_valid + errors.add(:milestone_id, message: "is invalid") if respond_to?(:milestone_id) && milestone_id.present? && !milestone_available? + end + + def write_to_new_milestone_relationship + self.milestones = [milestone].compact if supports_milestone? && saved_change_to_milestone_id? + end + end + + def milestone_available? + project_id == milestone&.project_id || project.ancestors_upto.compact.include?(milestone&.group) + end + + ## + # Overridden on EE module + # + def supports_milestone? + respond_to?(:milestone_id) + end +end + +Milestoneable.prepend_if_ee('EE::Milestoneable') diff --git a/app/models/concerns/project_features_compatibility.rb b/app/models/concerns/project_features_compatibility.rb index 551a2e56ecf33529028251b98a245376904bf8d8..eac676f30a504d91ce928f2efabe3077f469cb45 100644 --- a/app/models/concerns/project_features_compatibility.rb +++ b/app/models/concerns/project_features_compatibility.rb @@ -50,6 +50,10 @@ module ProjectFeaturesCompatibility write_feature_attribute_string(:merge_requests_access_level, value) end + def forking_access_level=(value) + write_feature_attribute_string(:forking_access_level, value) + end + def issues_access_level=(value) write_feature_attribute_string(:issues_access_level, value) end diff --git a/app/models/concerns/protected_ref.rb b/app/models/concerns/protected_ref.rb index d9a7f0a96dc65796adcb138c16b14d5284cd9ef0..cddca72f91f77999b8bf91a626f3136340355fac 100644 --- a/app/models/concerns/protected_ref.rb +++ b/app/models/concerns/protected_ref.rb @@ -10,6 +10,8 @@ module ProtectedRef validates :project, presence: true delegate :matching, :matches?, :wildcard?, to: :ref_matcher + + scope :for_project, ->(project) { where(project: project) } end def commit diff --git a/app/models/concerns/reactive_caching.rb b/app/models/concerns/reactive_caching.rb index 693f9ab8dc5019248dcae6fbfe958a6403782c9d..4b9896343c61fd209342a29f5d3ce2185717ecbc 100644 --- a/app/models/concerns/reactive_caching.rb +++ b/app/models/concerns/reactive_caching.rb @@ -1,78 +1,7 @@ # frozen_string_literal: true -# The ReactiveCaching concern is used to fetch some data in the background and -# store it in the Rails cache, keeping it up-to-date for as long as it is being -# requested. If the data hasn't been requested for +reactive_cache_lifetime+, -# it stop being refreshed, and then be removed. -# -# Example of use: -# -# class Foo < ApplicationRecord -# include ReactiveCaching -# -# after_save :clear_reactive_cache! -# -# def calculate_reactive_cache -# # Expensive operation here. The return value of this method is cached -# end -# -# def result -# with_reactive_cache do |data| -# # ... -# end -# end -# end -# -# In this example, the first time `#result` is called, it will return `nil`. -# However, it will enqueue a background worker to call `#calculate_reactive_cache` -# and set an initial cache lifetime of ten minutes. -# -# The background worker needs to find or generate the object on which -# `with_reactive_cache` was called. -# The default behaviour can be overridden by defining a custom -# `reactive_cache_worker_finder`. -# Otherwise the background worker will use the class name and primary key to get -# the object using the ActiveRecord find_by method. -# -# class Bar -# include ReactiveCaching -# -# self.reactive_cache_key = ->() { ["bar", "thing"] } -# self.reactive_cache_worker_finder = ->(_id, *args) { from_cache(*args) } -# -# def self.from_cache(var1, var2) -# # This method will be called by the background worker with "bar1" and -# # "bar2" as arguments. -# new(var1, var2) -# end -# -# def initialize(var1, var2) -# # ... -# end -# -# def calculate_reactive_cache -# # Expensive operation here. The return value of this method is cached -# end -# -# def result -# with_reactive_cache("bar1", "bar2") do |data| -# # ... -# end -# end -# end -# -# Each time the background job completes, it stores the return value of -# `#calculate_reactive_cache`. It is also re-enqueued to run again after -# `reactive_cache_refresh_interval`, so keeping the stored value up to date. -# Calculations are never run concurrently. -# -# Calling `#result` while a value is in the cache will call the block given to -# `#with_reactive_cache`, yielding the cached value. It will also extend the -# lifetime by `reactive_cache_lifetime`. -# -# Once the lifetime has expired, no more background jobs will be enqueued and -# calling `#result` will again return `nil` - starting the process all over -# again +# The usage of the ReactiveCaching module is documented here: +# https://docs.gitlab.com/ee/development/utilities.html#reactivecaching module ReactiveCaching extend ActiveSupport::Concern @@ -122,6 +51,14 @@ module ReactiveCaching end end + # This method is used for debugging purposes and should not be used otherwise. + def without_reactive_cache(*args, &blk) + return with_reactive_cache(*args, &blk) unless Rails.env.development? + + data = self.class.reactive_cache_worker_finder.call(id, *args).calculate_reactive_cache(*args) + yield data + end + def clear_reactive_cache!(*args) Rails.cache.delete(full_reactive_cache_key(*args)) Rails.cache.delete(alive_reactive_cache_key(*args)) diff --git a/app/models/concerns/referable.rb b/app/models/concerns/referable.rb index 4a506146de3f96fdaad138ee314045aa0bd9aa24..3b0606aa425c118456c5a176659a9d715e744bb9 100644 --- a/app/models/concerns/referable.rb +++ b/app/models/concerns/referable.rb @@ -74,7 +74,7 @@ module Referable #{Regexp.escape(Gitlab.config.gitlab.url)} \/#{Project.reference_pattern} (?:\/\-)? - \/#{Regexp.escape(route)} + \/#{route.is_a?(Regexp) ? route : Regexp.escape(route)} \/#{pattern} (?<path> (\/[a-z0-9_=-]+)* diff --git a/app/models/concerns/schedulable.rb b/app/models/concerns/schedulable.rb new file mode 100644 index 0000000000000000000000000000000000000000..6fdca4f50c3e7f1bebf61e7c9b7bc97bab05d301 --- /dev/null +++ b/app/models/concerns/schedulable.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Schedulable + extend ActiveSupport::Concern + + included do + scope :runnable_schedules, -> { active.where("next_run_at < ?", Time.zone.now) } + + before_save :set_next_run_at + end + + def schedule_next_run! + save! # with set_next_run_at + rescue ActiveRecord::RecordInvalid + update_column(:next_run_at, nil) # update without validation + end + + def set_next_run_at + raise NotImplementedError + end +end diff --git a/app/models/concerns/sha256_attribute.rb b/app/models/concerns/sha256_attribute.rb index 1bd1ad177a2a4e1724f3164ab8e2ad6f15f60551..9dfe1b77829d31a03da3325fc7c1998ca9c960b2 100644 --- a/app/models/concerns/sha256_attribute.rb +++ b/app/models/concerns/sha256_attribute.rb @@ -39,11 +39,7 @@ module Sha256Attribute end def database_exists? - ApplicationRecord.connection - - true - rescue - false + Gitlab::Database.exists? end end end diff --git a/app/models/concerns/sha_attribute.rb b/app/models/concerns/sha_attribute.rb index c5826f5896630fbe28b2b0ed6d3112ac1193f5ce..c807dcbf41884aa83783d9db425c6bcbe8d6925f 100644 --- a/app/models/concerns/sha_attribute.rb +++ b/app/models/concerns/sha_attribute.rb @@ -39,11 +39,7 @@ module ShaAttribute end def database_exists? - ApplicationRecord.connection - - true - rescue - false + Gitlab::Database.exists? end end end diff --git a/app/models/concerns/taskable.rb b/app/models/concerns/taskable.rb index 98842242eb6020f4d9e3fb43ec9bc675fc1c81a4..5debfa6f834bfc4a4d692409fe10aebbd6d8989f 100644 --- a/app/models/concerns/taskable.rb +++ b/app/models/concerns/taskable.rb @@ -15,11 +15,11 @@ module Taskable INCOMPLETE_PATTERN = /(\[[\s]\])/.freeze ITEM_PATTERN = %r{ ^ - (?:(?:>\s{0,4})*) # optional blockquote characters - \s*(?:[-+*]|(?:\d+\.)) # list prefix required - task item has to be always in a list - \s+ # whitespace prefix has to be always presented for a list item - (\[\s\]|\[[xX]\]) # checkbox - (\s.+) # followed by whitespace and some text. + (?:(?:>\s{0,4})*) # optional blockquote characters + (?:\s*(?:[-+*]|(?:\d+\.)))+ # list prefix (one or more) required - task item has to be always in a list + \s+ # whitespace prefix has to be always presented for a list item + (\[\s\]|\[[xX]\]) # checkbox + (\s.+) # followed by whitespace and some text. }x.freeze def self.get_tasks(content) diff --git a/app/models/container_expiration_policy.rb b/app/models/container_expiration_policy.rb index f60a0179c83f5c65b03aaeafe486f0eea8dc4d0c..c929a78a7f91636be0dda0d02841a6e229f272fd 100644 --- a/app/models/container_expiration_policy.rb +++ b/app/models/container_expiration_policy.rb @@ -1,14 +1,21 @@ # frozen_string_literal: true class ContainerExpirationPolicy < ApplicationRecord + include Schedulable + belongs_to :project, inverse_of: :container_expiration_policy + delegate :container_repositories, to: :project + validates :project, presence: true validates :enabled, inclusion: { in: [true, false] } validates :cadence, presence: true, inclusion: { in: ->(_) { self.cadence_options.stringify_keys } } validates :older_than, inclusion: { in: ->(_) { self.older_than_options.stringify_keys } }, allow_nil: true validates :keep_n, inclusion: { in: ->(_) { self.keep_n_options.keys } }, allow_nil: true + scope :active, -> { where(enabled: true) } + scope :preloaded, -> { preload(:project) } + def self.keep_n_options { 1 => _('%{tags} tag per image name') % { tags: 1 }, @@ -38,4 +45,8 @@ class ContainerExpirationPolicy < ApplicationRecord '90d': _('%{days} days until tags are automatically removed') % { days: 90 } } end + + def set_next_run_at + self.next_run_at = Time.zone.now + ChronicDuration.parse(cadence).seconds + end end diff --git a/app/models/deployment.rb b/app/models/deployment.rb index 994e69912b626f6a5a0ebf689c8cdd46d7eb74f7..e0daf692665beab108266d360cc0f5be5429a2f5 100644 --- a/app/models/deployment.rb +++ b/app/models/deployment.rb @@ -5,6 +5,7 @@ class Deployment < ApplicationRecord include IidRoutes include AfterCommitQueue include UpdatedAtFilterable + include Importable include Gitlab::Utils::StrongMemoize belongs_to :project, required: true @@ -17,16 +18,23 @@ class Deployment < ApplicationRecord has_many :merge_requests, through: :deployment_merge_requests - has_internal_id :iid, scope: :project, init: ->(s) do + has_internal_id :iid, scope: :project, track_if: -> { !importing? }, init: ->(s) do Deployment.where(project: s.project).maximum(:iid) if s&.project end validates :sha, presence: true validates :ref, presence: true + validate :valid_sha, on: :create + validate :valid_ref, on: :create delegate :name, to: :environment, prefix: true scope :for_environment, -> (environment) { where(environment_id: environment) } + scope :for_environment_name, -> (name) do + joins(:environment).where(environments: { name: name }) + end + + scope :for_status, -> (status) { where(status: status) } scope :visible, -> { where(status: %i[running success failed canceled]) } @@ -210,10 +218,14 @@ class Deployment < ApplicationRecord # We don't use `Gitlab::Database.bulk_insert` here so that we don't need to # first pluck lots of IDs into memory. + # + # We also ignore any duplicates so this method can be called multiple times + # for the same deployment, only inserting any missing merge requests. DeploymentMergeRequest.connection.execute(<<~SQL) INSERT INTO #{DeploymentMergeRequest.table_name} (merge_request_id, deployment_id) #{select} + ON CONFLICT DO NOTHING SQL end @@ -234,6 +246,18 @@ class Deployment < ApplicationRecord end end + def valid_sha + return if project&.commit(sha) + + errors.add(:sha, _('The commit does not exist')) + end + + def valid_ref + return if project&.commit(ref) + + errors.add(:ref, _('The branch or tag does not exist')) + end + private def ref_path diff --git a/app/models/deployment_metrics.rb b/app/models/deployment_metrics.rb index 2056c8bc59c9020d715950f107d2a98f1b49ae95..c5f8b03f25b3a0b18e55808f98d30f463d51b95c 100644 --- a/app/models/deployment_metrics.rb +++ b/app/models/deployment_metrics.rb @@ -13,18 +13,18 @@ class DeploymentMetrics end def has_metrics? - deployment.success? && prometheus_adapter&.can_query? + deployment.success? && prometheus_adapter&.configured? end def metrics - return {} unless has_metrics? + return {} unless has_metrics_and_can_query? metrics = prometheus_adapter.query(:deployment, deployment) metrics&.merge(deployment_time: deployment.finished_at.to_i) || {} end def additional_metrics - return {} unless has_metrics? + return {} unless has_metrics_and_can_query? metrics = prometheus_adapter.query(:additional_metrics_deployment, deployment) metrics&.merge(deployment_time: deployment.finished_at.to_i) || {} @@ -34,17 +34,11 @@ class DeploymentMetrics def prometheus_adapter strong_memoize(:prometheus_adapter) do - service = project.find_or_initialize_service('prometheus') - - if service.can_query? - service - else - cluster_prometheus - end + Gitlab::Prometheus::Adapter.new(project, cluster).prometheus_adapter end end - def cluster_prometheus - cluster.application_prometheus if cluster&.application_prometheus_available? + def has_metrics_and_can_query? + has_metrics? && prometheus_adapter.can_query? end end diff --git a/app/models/diff_note.rb b/app/models/diff_note.rb index 686d06d3ee0187aa3d0d32f2e35f3864698f69d9..939d8bc4befc16cab632767755ce6111f50fa1a3 100644 --- a/app/models/diff_note.rb +++ b/app/models/diff_note.rb @@ -23,6 +23,12 @@ class DiffNote < Note before_validation :set_line_code, if: :on_text?, unless: :importing? after_save :keep_around_commits, unless: :importing? + + NoteDiffFileCreationError = Class.new(StandardError) + + DIFF_LINE_NOT_FOUND_MESSAGE = "Failed to find diff line for: %{file_path}, old_line: %{old_line}, new_line: %{new_line}" + DIFF_FILE_NOT_FOUND_MESSAGE = "Failed to find diff file" + after_commit :create_diff_file, on: :create def discussion_class(*) @@ -33,7 +39,16 @@ class DiffNote < Note return unless should_create_diff_file? diff_file = fetch_diff_file + raise NoteDiffFileCreationError, DIFF_FILE_NOT_FOUND_MESSAGE unless diff_file + diff_line = diff_file.line_for_position(self.original_position) + unless diff_line + raise NoteDiffFileCreationError, DIFF_LINE_NOT_FOUND_MESSAGE % { + file_path: diff_file.file_path, + old_line: original_position.old_line, + new_line: original_position.new_line + } + end creation_params = diff_file.diff.to_hash .except(:too_large) @@ -110,19 +125,20 @@ class DiffNote < Note def fetch_diff_file return note_diff_file.raw_diff_file if note_diff_file - file = - if created_at_diff?(noteable.diff_refs) - # We're able to use the already persisted diffs (Postgres) if we're - # presenting a "current version" of the MR discussion diff. - # So no need to make an extra Gitaly diff request for it. - # As an extra benefit, the returned `diff_file` already - # has `highlighted_diff_lines` data set from Redis on - # `Diff::FileCollection::MergeRequestDiff`. - noteable.diffs(original_position.diff_options).diff_files.first - else - original_position.diff_file(repository) - end + if created_at_diff?(noteable.diff_refs) + # We're able to use the already persisted diffs (Postgres) if we're + # presenting a "current version" of the MR discussion diff. + # So no need to make an extra Gitaly diff request for it. + # As an extra benefit, the returned `diff_file` already + # has `highlighted_diff_lines` data set from Redis on + # `Diff::FileCollection::MergeRequestDiff`. + file = noteable.diffs(original_position.diff_options).diff_files.first + # if line is not found in persisted diffs, fallback and retrieve file from repository using gitaly + # This is required because of https://gitlab.com/gitlab-org/gitlab/issues/42676 + file = nil if file&.line_for_position(original_position).nil? && importing? + end + file ||= original_position.diff_file(repository) file&.unfold_diff_lines(position) file diff --git a/app/models/diff_viewer/base.rb b/app/models/diff_viewer/base.rb index 22c8fe7356362b32b0f73b7b8a22f6d06669999d..75aa51348c8cf0fdc567f9db4dea952646419a66 100644 --- a/app/models/diff_viewer/base.rb +++ b/app/models/diff_viewer/base.rb @@ -4,7 +4,7 @@ module DiffViewer class Base PARTIAL_PATH_PREFIX = 'projects/diffs/viewers' - class_attribute :partial_name, :type, :extensions, :file_types, :binary, :switcher_icon, :switcher_title + class_attribute :partial_name, :type, :extensions, :binary, :switcher_icon, :switcher_title # These limits relate to the sum of the old and new blob sizes. # Limits related to the actual size of the diff are enforced in Gitlab::Diff::File. @@ -50,7 +50,6 @@ module DiffViewer return true if blob.nil? return false if verify_binary && binary? != blob.binary_in_repo? return true if extensions&.include?(blob.extension) - return true if file_types&.include?(blob.file_type) false end @@ -89,7 +88,7 @@ module DiffViewer { viewer: switcher_title, reason: render_error_reason, - options: render_error_options.to_sentence(two_words_connector: _(' or '), last_word_connector: _(', or ')) + options: Gitlab::Utils.to_exclusive_sentence(render_error_options) } end diff --git a/app/models/diff_viewer/collapsed.rb b/app/models/diff_viewer/collapsed.rb new file mode 100644 index 0000000000000000000000000000000000000000..b533bd8b88d509926f3d4f7b50a61a52f20693ee --- /dev/null +++ b/app/models/diff_viewer/collapsed.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module DiffViewer + class Collapsed < Base + include Simple + include Static + + self.partial_name = 'collapsed' + end +end diff --git a/app/models/environment.rb b/app/models/environment.rb index b928dcb21a6cb0c34eff4eae0f51e0a031746d40..2d480345b5af3df1f2cbeb27f6183c389a81be10 100644 --- a/app/models/environment.rb +++ b/app/models/environment.rb @@ -48,13 +48,14 @@ class Environment < ApplicationRecord scope :available, -> { with_state(:available) } scope :stopped, -> { with_state(:stopped) } + scope :order_by_last_deployed_at, -> do - max_deployment_id_sql = - Deployment.select(Deployment.arel_table[:id].maximum) - .where(Deployment.arel_table[:environment_id].eq(arel_table[:id])) - .to_sql order(Gitlab::Database.nulls_first_order("(#{max_deployment_id_sql})", 'ASC')) end + scope :order_by_last_deployed_at_desc, -> do + order(Gitlab::Database.nulls_last_order("(#{max_deployment_id_sql})", 'DESC')) + end + scope :in_review_folder, -> { where(environment_type: "review") } scope :for_name, -> (name) { where(name: name) } scope :preload_cluster, -> { preload(last_deployment: :cluster) } @@ -90,6 +91,12 @@ class Environment < ApplicationRecord end end + def self.max_deployment_id_sql + Deployment.select(Deployment.arel_table[:id].maximum) + .where(Deployment.arel_table[:environment_id].eq(arel_table[:id])) + .to_sql + end + def self.pluck_names pluck(:name) end @@ -197,11 +204,15 @@ class Environment < ApplicationRecord end def has_metrics? - available? && prometheus_adapter&.configured? + available? && (prometheus_adapter&.configured? || has_sample_metrics?) + end + + def has_sample_metrics? + !!ENV['USE_SAMPLE_METRICS'] end def metrics - prometheus_adapter.query(:environment, self) if has_metrics? && prometheus_adapter.can_query? + prometheus_adapter.query(:environment, self) if has_metrics_and_can_query? end def prometheus_status @@ -209,16 +220,14 @@ class Environment < ApplicationRecord end def additional_metrics(*args) - return unless has_metrics? + return unless has_metrics_and_can_query? prometheus_adapter.query(:additional_metrics_environment, self, *args.map(&:to_f)) end - # rubocop: disable CodeReuse/ServiceClass def prometheus_adapter - @prometheus_adapter ||= Prometheus::AdapterService.new(project, deployment_platform).prometheus_adapter + @prometheus_adapter ||= Gitlab::Prometheus::Adapter.new(project, deployment_platform&.cluster).prometheus_adapter end - # rubocop: enable CodeReuse/ServiceClass def slug super.presence || generate_slug @@ -278,6 +287,10 @@ class Environment < ApplicationRecord private + def has_metrics_and_can_query? + has_metrics? && prometheus_adapter.can_query? + end + def generate_slug self.slug = Gitlab::Slug::Environment.new(name).generate end diff --git a/app/models/epic.rb b/app/models/epic.rb index 8222bbf9656d3faedddd6d91af9dbf2116c0aedf..1203c6c1fc3d04e853edcd5b29a7e2eec882fce1 100644 --- a/app/models/epic.rb +++ b/app/models/epic.rb @@ -5,7 +5,7 @@ class Epic < ApplicationRecord include IgnorableColumns - ignore_column :milestone_id, remove_after: '2019-12-15', remove_with: '12.7' + ignore_column :milestone_id, remove_after: '2020-02-01', remove_with: '12.8' def self.link_reference_pattern nil diff --git a/app/models/error_tracking/project_error_tracking_setting.rb b/app/models/error_tracking/project_error_tracking_setting.rb index 6a9986e806ba57d1d8707be7abfea79a5098591a..a904cf4ac4641949f0e03438ee40fe324ab899ee 100644 --- a/app/models/error_tracking/project_error_tracking_setting.rb +++ b/app/models/error_tracking/project_error_tracking_setting.rb @@ -4,6 +4,7 @@ module ErrorTracking class ProjectErrorTrackingSetting < ApplicationRecord include Gitlab::Utils::StrongMemoize include ReactiveCaching + include Gitlab::Routing SENTRY_API_ERROR_TYPE_BAD_REQUEST = 'bad_request_for_sentry_api' SENTRY_API_ERROR_TYPE_MISSING_KEYS = 'missing_keys_in_sentry_response' @@ -101,34 +102,33 @@ module ErrorTracking end end + def update_issue(opts = {} ) + handle_exceptions do + { updated: sentry_client.update_issue(opts) } + end + end + def calculate_reactive_cache(request, opts) - case request - when 'list_issues' - sentry_client.list_issues(**opts.symbolize_keys) - when 'issue_details' - { - issue: sentry_client.issue_details(**opts.symbolize_keys) - } - when 'issue_latest_event' - { - latest_event: sentry_client.issue_latest_event(**opts.symbolize_keys) - } + handle_exceptions do + case request + when 'list_issues' + sentry_client.list_issues(**opts.symbolize_keys) + when 'issue_details' + issue = sentry_client.issue_details(**opts.symbolize_keys) + { issue: add_gitlab_issue_details(issue) } + when 'issue_latest_event' + { + latest_event: sentry_client.issue_latest_event(**opts.symbolize_keys) + } + end end - rescue Sentry::Client::Error => e - { error: e.message, error_type: SENTRY_API_ERROR_TYPE_NON_20X_RESPONSE } - rescue Sentry::Client::MissingKeysError => e - { error: e.message, error_type: SENTRY_API_ERROR_TYPE_MISSING_KEYS } - rescue Sentry::Client::ResponseInvalidSizeError => e - { error: e.message, error_type: SENTRY_API_ERROR_INVALID_SIZE } - rescue Sentry::Client::BadRequestError => e - { error: e.message, error_type: SENTRY_API_ERROR_TYPE_BAD_REQUEST } end # http://HOST/api/0/projects/ORG/PROJECT # -> # http://HOST/ORG/PROJECT def self.extract_sentry_external_url(url) - url.sub('api/0/projects/', '') + url&.sub('api/0/projects/', '') end def api_host @@ -140,6 +140,36 @@ module ErrorTracking private + def add_gitlab_issue_details(issue) + issue.gitlab_commit = match_gitlab_commit(issue.first_release_version) + issue.gitlab_commit_path = project_commit_path(project, issue.gitlab_commit) if issue.gitlab_commit + + issue + end + + def match_gitlab_commit(release_version) + return unless release_version + + commit = project.repository.commit(release_version) + + commit&.id + end + + def handle_exceptions + yield + rescue Sentry::Client::Error => e + { error: e.message, error_type: SENTRY_API_ERROR_TYPE_NON_20X_RESPONSE } + rescue Sentry::Client::MissingKeysError => e + { error: e.message, error_type: SENTRY_API_ERROR_TYPE_MISSING_KEYS } + rescue Sentry::Client::ResponseInvalidSizeError => e + { error: e.message, error_type: SENTRY_API_ERROR_INVALID_SIZE } + rescue Sentry::Client::BadRequestError => e + { error: e.message, error_type: SENTRY_API_ERROR_TYPE_BAD_REQUEST } + rescue StandardError => e + Gitlab::ErrorTracking.track_exception(e) + { error: 'Unexpected Error' } + end + def project_name_from_slug @project_name_from_slug ||= project_slug_from_api_url&.titleize end diff --git a/app/models/event_collection.rb b/app/models/event_collection.rb index 4778f74568e344bfd70bd4b33de0386aa3b633d7..4768506b8fad2b3ee6837cd2e9134d59eacc3f4e 100644 --- a/app/models/event_collection.rb +++ b/app/models/event_collection.rb @@ -30,17 +30,24 @@ class EventCollection relation = if groups project_and_group_events else - relation_with_join_lateral('project_id', projects) + project_events end relation = paginate_events(relation) relation.with_associations.to_a end + def all_project_events + Event.from_union([project_events]).recent + end + private + def project_events + relation_with_join_lateral('project_id', projects) + end + def project_and_group_events - project_events = relation_with_join_lateral('project_id', projects) group_events = relation_with_join_lateral('group_id', groups) Event.from_union([project_events, group_events]).recent diff --git a/app/models/group.rb b/app/models/group.rb index 8289d4f099c0b61dd2daa1041937b65cb7f192c2..b642b177df1e01461cb1b4220511f0a91423a51d 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -57,6 +57,8 @@ class Group < Namespace has_one :import_export_upload + has_many :import_failures, inverse_of: :group + accepts_nested_attributes_for :variables, allow_destroy: true validate :visibility_level_allowed_by_projects @@ -420,6 +422,12 @@ class Group < Namespace GroupMember.where(source_id: self_and_ancestors_ids, user_id: user.id).order(:access_level).last end + def related_group_ids + [id, + *ancestors.pluck(:id), + *shared_with_group_links.pluck(:shared_with_group_id)] + end + def hashed_storage?(_feature) false end @@ -490,7 +498,7 @@ class Group < Namespace end def max_member_access_for_user_from_shared_groups(user) - return unless Feature.enabled?(:share_group_with_group) + return unless Feature.enabled?(:share_group_with_group, default_enabled: true) group_group_link_table = GroupGroupLink.arel_table group_member_table = GroupMember.arel_table diff --git a/app/models/group_group_link.rb b/app/models/group_group_link.rb index 4b279b7af5bd3f6a25a17b149ae9a0d9c426de65..5a0d9b08cb06e6f13c91ff4c8b3255d0598aba23 100644 --- a/app/models/group_group_link.rb +++ b/app/models/group_group_link.rb @@ -20,4 +20,8 @@ class GroupGroupLink < ApplicationRecord def self.default_access Gitlab::Access::DEVELOPER end + + def human_access + Gitlab::Access.human_access(self.group_access) + end end diff --git a/app/models/import_failure.rb b/app/models/import_failure.rb index 998572853d366a3c93fc855e8f1bf1ca1c3115b7..a1e03218640efe711993e26a95f3c5a2389e327e 100644 --- a/app/models/import_failure.rb +++ b/app/models/import_failure.rb @@ -2,6 +2,8 @@ class ImportFailure < ApplicationRecord belongs_to :project + belongs_to :group - validates :project, presence: true + validates :project, presence: true, unless: :group + validates :group, presence: true, unless: :project end diff --git a/app/models/issue.rb b/app/models/issue.rb index 88df3baa809a8e00bd9f4e19e8964b7cc9de4e8a..bf6002781627e134944eebf18e334285a40eb0f7 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -31,7 +31,10 @@ class Issue < ApplicationRecord belongs_to :duplicated_to, class_name: 'Issue' belongs_to :closed_by, class_name: 'User' - has_internal_id :iid, scope: :project, init: ->(s) { s&.project&.issues&.maximum(:iid) } + has_internal_id :iid, scope: :project, track_if: -> { !importing? }, init: ->(s) { s&.project&.issues&.maximum(:iid) } + + has_many :issue_milestones + has_many :milestones, through: :issue_milestones has_many :events, as: :target, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent @@ -75,8 +78,8 @@ class Issue < ApplicationRecord ignore_column :state, remove_with: '12.7', remove_after: '2019-12-22' - after_commit :expire_etag_cache - after_save :ensure_metrics, unless: :imported? + after_commit :expire_etag_cache, unless: :importing? + after_save :ensure_metrics, unless: :importing? attr_spammable :title, spam_title: true attr_spammable :description, spam_description: true diff --git a/app/models/issue_milestone.rb b/app/models/issue_milestone.rb new file mode 100644 index 0000000000000000000000000000000000000000..da030077d8718ec11a727bc575004f6d46fef5c4 --- /dev/null +++ b/app/models/issue_milestone.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +class IssueMilestone < ApplicationRecord + belongs_to :milestone + belongs_to :issue +end diff --git a/app/models/key.rb b/app/models/key.rb index e549c59b58fc7a91d2917fdd0f9d2ae4134b0f1b..71188f210bb42758ed03d858a21b4909cac1fbfc 100644 --- a/app/models/key.rb +++ b/app/models/key.rb @@ -142,13 +142,9 @@ class Key < ApplicationRecord end def forbidden_key_type_message - allowed_types = - Gitlab::CurrentSettings - .allowed_key_types - .map(&:upcase) - .to_sentence(last_word_connector: ', or ', two_words_connector: ' or ') + allowed_types = Gitlab::CurrentSettings.allowed_key_types.map(&:upcase) - "type is forbidden. Must be #{allowed_types}" + "type is forbidden. Must be #{Gitlab::Utils.to_exclusive_sentence(allowed_types)}" end end diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 2280c5280d58ae72c8d2f7532367fa4092f4c8b8..7162ba08a7632bfcbacea87d342f02310a87fe35 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -31,10 +31,13 @@ class MergeRequest < ApplicationRecord belongs_to :source_project, class_name: "Project" belongs_to :merge_user, class_name: "User" - has_internal_id :iid, scope: :target_project, init: ->(s) { s&.target_project&.merge_requests&.maximum(:iid) } + has_internal_id :iid, scope: :target_project, track_if: -> { !importing? }, init: ->(s) { s&.target_project&.merge_requests&.maximum(:iid) } has_many :merge_request_diffs + has_many :merge_request_milestones + has_many :milestones, through: :merge_request_milestones + has_one :merge_request_diff, -> { order('merge_request_diffs.id DESC') }, inverse_of: :merge_request @@ -94,8 +97,8 @@ class MergeRequest < ApplicationRecord after_create :ensure_merge_request_diff after_update :clear_memoized_shas after_update :reload_diff_if_branch_changed - after_save :ensure_metrics - after_commit :expire_etag_cache + after_save :ensure_metrics, unless: :importing? + after_commit :expire_etag_cache, unless: :importing? # When this attribute is true some MR validation is ignored # It allows us to close or modify broken merge requests @@ -255,12 +258,10 @@ class MergeRequest < ApplicationRecord alias_method :issuing_parent, :target_project delegate :active?, to: :head_pipeline, prefix: true, allow_nil: true - delegate :success?, to: :actual_head_pipeline, prefix: true, allow_nil: true + delegate :success?, :active?, to: :actual_head_pipeline, prefix: true, allow_nil: true RebaseLockTimeout = Class.new(StandardError) - REBASE_LOCK_MESSAGE = _("Failed to enqueue the rebase operation, possibly due to a long-lived transaction. Try again later.") - def self.reference_prefix '!' end @@ -448,7 +449,7 @@ class MergeRequest < ApplicationRecord # Set off a rebase asynchronously, atomically updating the `rebase_jid` of # the MR so that the status of the operation can be tracked. - def rebase_async(user_id) + def rebase_async(user_id, skip_ci: false) with_rebase_lock do raise ActiveRecord::StaleObjectError if !open? || rebase_in_progress? @@ -457,7 +458,7 @@ class MergeRequest < ApplicationRecord # attribute is set *and* that the sidekiq job is still running. So a JID # for a completed RebaseWorker is equivalent to a nil JID. jid = Sidekiq::Worker.skipping_transaction_check do - RebaseWorker.perform_async(id, user_id) + RebaseWorker.perform_async(id, user_id, skip_ci) end update_column(:rebase_jid, jid) @@ -1122,22 +1123,18 @@ class MergeRequest < ApplicationRecord actual_head_pipeline.success? end - def environments_for(current_user) + def environments_for(current_user, latest: false) return [] unless diff_head_commit - @environments ||= Hash.new do |h, current_user| - envs = EnvironmentsFinder.new(target_project, current_user, - ref: target_branch, commit: diff_head_commit, with_tags: true).execute + envs = EnvironmentsFinder.new(target_project, current_user, + ref: target_branch, commit: diff_head_commit, with_tags: true, find_latest: latest).execute - if source_project - envs.concat EnvironmentsFinder.new(source_project, current_user, - ref: source_branch, commit: diff_head_commit).execute - end - - h[current_user] = envs.uniq + if source_project + envs.concat EnvironmentsFinder.new(source_project, current_user, + ref: source_branch, commit: diff_head_commit, find_latest: latest).execute end - @environments[current_user] + envs.uniq end ## @@ -1515,7 +1512,7 @@ class MergeRequest < ApplicationRecord end rescue ActiveRecord::LockWaitTimeout => e Gitlab::ErrorTracking.track_exception(e) - raise RebaseLockTimeout, REBASE_LOCK_MESSAGE + raise RebaseLockTimeout, _('Failed to enqueue the rebase operation, possibly due to a long-lived transaction. Try again later.') end def source_project_variables diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb index 71a344e69e3560858284edbc2870fece2e7db172..fa633a1a7257643edf446e00c14f446b4660b3db 100644 --- a/app/models/merge_request_diff.rb +++ b/app/models/merge_request_diff.rb @@ -138,7 +138,7 @@ class MergeRequestDiff < ApplicationRecord # All diff information is collected from repository after object is created. # It allows you to override variables like head_commit_sha before getting diff. after_create :save_git_content, unless: :importing? - after_create_commit :set_as_latest_diff + after_create_commit :set_as_latest_diff, unless: :importing? after_save :update_external_diff_store, if: -> { !importing? && saved_change_to_external_diff? } diff --git a/app/models/merge_request_milestone.rb b/app/models/merge_request_milestone.rb new file mode 100644 index 0000000000000000000000000000000000000000..4fa1d1dcb337864eb5e036fec79ee19b6b42dea2 --- /dev/null +++ b/app/models/merge_request_milestone.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +class MergeRequestMilestone < ApplicationRecord + belongs_to :milestone + belongs_to :merge_request +end diff --git a/app/models/milestone.rb b/app/models/milestone.rb index 987373aaf1bc0ee1b6f56599f4bd0e0b658a2b8a..5da92fc4bc547fb59af1bd819749593c16e6e729 100644 --- a/app/models/milestone.rb +++ b/app/models/milestone.rb @@ -17,6 +17,7 @@ class Milestone < ApplicationRecord include StripAttribute include Milestoneish include FromUnion + include Importable include Gitlab::SQL::Pattern prepend_if_ee('::EE::Milestone') # rubocop: disable Cop/InjectEnterpriseEditionModule @@ -30,14 +31,17 @@ class Milestone < ApplicationRecord has_many :milestone_releases has_many :releases, through: :milestone_releases - has_internal_id :iid, scope: :project, init: ->(s) { s&.project&.milestones&.maximum(:iid) } - has_internal_id :iid, scope: :group, init: ->(s) { s&.group&.milestones&.maximum(:iid) } + has_internal_id :iid, scope: :project, track_if: -> { !importing? }, init: ->(s) { s&.project&.milestones&.maximum(:iid) } + has_internal_id :iid, scope: :group, track_if: -> { !importing? }, init: ->(s) { s&.group&.milestones&.maximum(:iid) } has_many :issues has_many :labels, -> { distinct.reorder('labels.title') }, through: :issues has_many :merge_requests has_many :events, as: :target, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent + has_many :issue_milestones + has_many :merge_request_milestones + scope :of_projects, ->(ids) { where(project_id: ids) } scope :of_groups, ->(ids) { where(group_id: ids) } scope :active, -> { with_state(:active) } diff --git a/app/models/namespace.rb b/app/models/namespace.rb index d5a7c172fec7fb0861403b44496dffe8aa4262a3..621a98e9ab6abb12f0cbd5a8d3e7819e54198126 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -46,6 +46,8 @@ class Namespace < ApplicationRecord length: { maximum: 255 }, namespace_path: true + validates :max_artifacts_size, numericality: { only_integer: true, greater_than: 0, allow_nil: true } + validate :nesting_level_allowed validates_associated :runners @@ -184,7 +186,11 @@ class Namespace < ApplicationRecord # any ancestor can disable emails for all descendants def emails_disabled? strong_memoize(:emails_disabled) do - self_and_ancestors.where(emails_disabled: true).exists? + if parent_id + self_and_ancestors.where(emails_disabled: true).exists? + else + !!emails_disabled + end end end diff --git a/app/models/note.rb b/app/models/note.rb index cfa7ba980814aad0b059bc28364268821f4a29b6..7731b477ad063b1b19af43ed33ba1ff1c34a7202 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -152,9 +152,7 @@ class Note < ApplicationRecord scope :for_note_or_capitalized_note, ->(text) { where(note: [text, text.capitalize]) } scope :like_note_or_capitalized_note, ->(text) { where('(note LIKE ? OR note LIKE ?)', text, text.capitalize) } - after_initialize :ensure_discussion_id before_validation :nullify_blank_type, :nullify_blank_line_code - before_validation :set_discussion_id, on: :create after_save :keep_around_commit, if: :for_project_noteable?, unless: :importing? after_save :expire_etag_cache, unless: :importing? after_save :touch_noteable, unless: :importing? @@ -394,7 +392,7 @@ class Note < ApplicationRecord # See `Discussion.override_discussion_id` for details. def discussion_id(noteable = nil) - discussion_class(noteable).override_discussion_id(self) || super() + discussion_class(noteable).override_discussion_id(self) || super() || ensure_discussion_id end # Returns a discussion containing just this note. @@ -533,17 +531,13 @@ class Note < ApplicationRecord end def ensure_discussion_id - return unless self.persisted? - # Needed in case the SELECT statement doesn't ask for `discussion_id` - return unless self.has_attribute?(:discussion_id) - return if self.discussion_id + return if self.attribute_present?(:discussion_id) - set_discussion_id - update_column(:discussion_id, self.discussion_id) + self.discussion_id = derive_discussion_id end - def set_discussion_id - self.discussion_id ||= discussion_class.discussion_id(self) + def derive_discussion_id + discussion_class.discussion_id(self) end def all_referenced_mentionables_allowed?(user) diff --git a/app/models/project.rb b/app/models/project.rb index 3f6c2d6a448cfc76f226727ee3a6663ac03df94a..c48360290c7b0e459c3324259556faeda0dcf802 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -63,10 +63,6 @@ class Project < ApplicationRecord cache_markdown_field :description, pipeline: :description - # TODO: remove once GitLab 12.5 is released - # https://gitlab.com/gitlab-org/gitlab/issues/34638 - ignore_column :merge_requests_require_code_owner_approval, remove_after: '2019-12-01', remove_with: '12.6' - default_value_for :archived, false default_value_for :resolve_outdated_diff_discussions, false default_value_for :container_registry_enabled, gitlab_config_features.container_registry @@ -79,6 +75,7 @@ class Project < ApplicationRecord default_value_for :snippets_enabled, gitlab_config_features.snippets default_value_for :only_allow_merge_if_all_discussions_are_resolved, false default_value_for :remove_source_branch_after_merge, true + default_value_for :autoclose_referenced_issues, true default_value_for(:ci_config_path) { Gitlab::CurrentSettings.default_ci_config_path } add_authentication_token_field :runners_token, encrypted: -> { Feature.enabled?(:projects_tokens_optional_encryption, default_enabled: true) ? :optional : :required } @@ -285,6 +282,7 @@ class Project < ApplicationRecord has_many :pipeline_schedules, class_name: 'Ci::PipelineSchedule' has_many :project_deploy_tokens has_many :deploy_tokens, through: :project_deploy_tokens + has_many :resource_groups, class_name: 'Ci::ResourceGroup', inverse_of: :project has_one :auto_devops, class_name: 'ProjectAutoDevops', inverse_of: :project, autosave: true has_many :custom_attributes, class_name: 'ProjectCustomAttribute' @@ -319,10 +317,12 @@ class Project < ApplicationRecord accepts_nested_attributes_for :metrics_setting, update_only: true, allow_destroy: true accepts_nested_attributes_for :grafana_integration, update_only: true, allow_destroy: true - delegate :feature_available?, :builds_enabled?, :wiki_enabled?, :merge_requests_enabled?, - :issues_enabled?, :pages_enabled?, :public_pages?, :private_pages?, - :merge_requests_access_level, :issues_access_level, :wiki_access_level, - :snippets_access_level, :builds_access_level, :repository_access_level, + delegate :feature_available?, :builds_enabled?, :wiki_enabled?, + :merge_requests_enabled?, :forking_enabled?, :issues_enabled?, + :pages_enabled?, :public_pages?, :private_pages?, + :merge_requests_access_level, :forking_access_level, :issues_access_level, + :wiki_access_level, :snippets_access_level, :builds_access_level, + :repository_access_level, to: :project_feature, allow_nil: true delegate :scheduled?, :started?, :in_progress?, :failed?, :finished?, prefix: :import, to: :import_state, allow_nil: true @@ -334,7 +334,7 @@ class Project < ApplicationRecord 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 :root_ancestor, :actual_limits, to: :namespace, allow_nil: true + delegate :root_ancestor, to: :namespace, allow_nil: true delegate :last_pipeline, to: :commit, allow_nil: true delegate :external_dashboard_url, to: :metrics_setting, allow_nil: true, prefix: true delegate :default_git_depth, :default_git_depth=, to: :ci_cd_settings, prefix: :ci @@ -374,6 +374,7 @@ class Project < ApplicationRecord 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 } + validates :max_artifacts_size, numericality: { only_integer: true, greater_than: 0, allow_nil: true } # Scopes scope :pending_delete, -> { where(pending_delete: true) } @@ -681,6 +682,12 @@ class Project < ApplicationRecord end end + def autoclose_referenced_issues + return true if super.nil? + + super + end + def preload_protected_branches preloader = ActiveRecord::Associations::Preloader.new preloader.preload(self, protected_branches: [:push_access_levels, :merge_access_levels]) @@ -1320,7 +1327,7 @@ class Project < ApplicationRecord end def has_active_hooks?(hooks_scope = :push_hooks) - hooks.hooks_for(hooks_scope).any? || SystemHook.hooks_for(hooks_scope).any? || Gitlab::Plugin.any? + hooks.hooks_for(hooks_scope).any? || SystemHook.hooks_for(hooks_scope).any? || Gitlab::FileHook.any? end def has_active_services?(hooks_scope = :push_hooks) @@ -1509,7 +1516,7 @@ class Project < ApplicationRecord end def default_branch - @default_branch ||= repository.root_ref if repository.exists? + @default_branch ||= repository.root_ref end def reload_default_branch @@ -1927,6 +1934,7 @@ class Project < ApplicationRecord Gitlab::Ci::Variables::Collection.new .append(key: 'CI', value: 'true') .append(key: 'GITLAB_CI', value: 'true') + .append(key: 'CI_SERVER_URL', value: Gitlab.config.gitlab.url) .append(key: 'CI_SERVER_HOST', value: Gitlab.config.gitlab.host) .append(key: 'CI_SERVER_NAME', value: 'GitLab') .append(key: 'CI_SERVER_VERSION', value: Gitlab::VERSION) diff --git a/app/models/project_ci_cd_setting.rb b/app/models/project_ci_cd_setting.rb index b292d39dae7de3d0b15344b3f7e258542ea30413..1dd65c762582abb5fdbbf36f17ebe4e5a7b0bffd 100644 --- a/app/models/project_ci_cd_setting.rb +++ b/app/models/project_ci_cd_setting.rb @@ -4,7 +4,6 @@ class ProjectCiCdSetting < ApplicationRecord include IgnorableColumns # https://gitlab.com/gitlab-org/gitlab/issues/36651 ignore_column :merge_trains_enabled, remove_with: '12.7', remove_after: '2019-12-22' - belongs_to :project, inverse_of: :ci_cd_settings # The version of the schema that first introduced this model/table. diff --git a/app/models/project_feature.rb b/app/models/project_feature.rb index 4973c7761c17da729fdd83c5f109d226b8b7a061..a9753c3c53a01299317611cd801e1ba455c6355f 100644 --- a/app/models/project_feature.rb +++ b/app/models/project_feature.rb @@ -22,7 +22,7 @@ class ProjectFeature < ApplicationRecord ENABLED = 20 PUBLIC = 30 - FEATURES = %i(issues merge_requests wiki snippets builds repository pages).freeze + FEATURES = %i(issues forking merge_requests wiki snippets builds repository pages).freeze PRIVATE_FEATURES_MIN_ACCESS_LEVEL = { merge_requests: Gitlab::Access::REPORTER }.freeze PRIVATE_FEATURES_MIN_ACCESS_LEVEL_FOR_PRIVATE_PROJECT = { repository: Gitlab::Access::REPORTER }.freeze STRING_OPTIONS = HashWithIndifferentAccess.new({ @@ -92,12 +92,19 @@ class ProjectFeature < ApplicationRecord default_value_for :builds_access_level, value: ENABLED, allows_nil: false default_value_for :issues_access_level, value: ENABLED, allows_nil: false + default_value_for :forking_access_level, value: ENABLED, allows_nil: false default_value_for :merge_requests_access_level, value: ENABLED, allows_nil: false default_value_for :snippets_access_level, value: ENABLED, allows_nil: false default_value_for :wiki_access_level, value: ENABLED, allows_nil: false default_value_for :repository_access_level, value: ENABLED, allows_nil: false - default_value_for(:pages_access_level, allows_nil: false) { |feature| feature.project&.public? ? ENABLED : PRIVATE } + default_value_for(:pages_access_level, allows_nil: false) do |feature| + if ::Gitlab::Pages.access_control_is_forced? + PRIVATE + else + feature.project&.public? ? ENABLED : PRIVATE + end + end def feature_available?(feature, user) # This feature might not be behind a feature flag at all, so default to true @@ -126,6 +133,10 @@ class ProjectFeature < ApplicationRecord merge_requests_access_level > DISABLED end + def forking_enabled? + forking_access_level > DISABLED + end + def issues_enabled? issues_access_level > DISABLED end @@ -137,6 +148,8 @@ class ProjectFeature < ApplicationRecord def public_pages? return true unless Gitlab.config.pages.access_control + return false if ::Gitlab::Pages.access_control_is_forced? + pages_access_level == PUBLIC || pages_access_level == ENABLED && project.public? end diff --git a/app/models/project_group_link.rb b/app/models/project_group_link.rb index 0d3a2d4e3988d77b9a590c7b839110080065ec48..b70c07a83862261775cd90e0203f2813ba1e5cba 100644 --- a/app/models/project_group_link.rb +++ b/app/models/project_group_link.rb @@ -21,6 +21,8 @@ class ProjectGroupLink < ApplicationRecord after_commit :refresh_group_members_authorized_projects + alias_method :shared_with_group, :group + def self.access_options Gitlab::Access.options end diff --git a/app/models/project_services/chat_message/base_message.rb b/app/models/project_services/chat_message/base_message.rb index 6542112ba3251f5724cec7d01e81e99c86ad526d..529af1277b01eba08956adbdec02ac2a12b3e479 100644 --- a/app/models/project_services/chat_message/base_message.rb +++ b/app/models/project_services/chat_message/base_message.rb @@ -4,6 +4,8 @@ require 'slack-notifier' module ChatMessage class BaseMessage + RELATIVE_LINK_REGEX = /!\[[^\]]*\]\((\/uploads\/[^\)]*)\)/.freeze + attr_reader :markdown attr_reader :user_full_name attr_reader :user_name @@ -59,7 +61,11 @@ module ChatMessage end def format(string) - Slack::Notifier::LinkFormatter.format(string) + Slack::Notifier::LinkFormatter.format(format_relative_links(string)) + end + + def format_relative_links(string) + string.gsub(RELATIVE_LINK_REGEX, "#{project_url}\\1") end def attachment_color diff --git a/app/models/project_services/chat_message/wiki_page_message.rb b/app/models/project_services/chat_message/wiki_page_message.rb index b605d2892782e7363d7690d64dc4852c4c9801cf..ebe7abb379f7c090fe16329c14c65dacf1625d52 100644 --- a/app/models/project_services/chat_message/wiki_page_message.rb +++ b/app/models/project_services/chat_message/wiki_page_message.rb @@ -14,7 +14,7 @@ module ChatMessage obj_attr = HashWithIndifferentAccess.new(obj_attr) @title = obj_attr[:title] @wiki_page_url = obj_attr[:url] - @description = obj_attr[:content] + @description = obj_attr[:message] @action = case obj_attr[:action] diff --git a/app/models/project_services/emails_on_push_service.rb b/app/models/project_services/emails_on_push_service.rb index 8ca40138a8ff7be95f8094c5c93a06f2d6d6efd9..eb78938324dfc91fff2fd5f1822785c7a9445506 100644 --- a/app/models/project_services/emails_on_push_service.rb +++ b/app/models/project_services/emails_on_push_service.rb @@ -1,9 +1,11 @@ # frozen_string_literal: true class EmailsOnPushService < Service + include NotificationBranchSelection + boolean_accessor :send_from_committer_email boolean_accessor :disable_diffs - prop_accessor :recipients + prop_accessor :recipients, :branches_to_be_notified validates :recipients, presence: true, if: :valid_recipients? def title @@ -22,9 +24,17 @@ class EmailsOnPushService < Service %w(push tag_push) end + def initialize_properties + if properties.nil? + self.properties = {} + self.branches_to_be_notified ||= "all" + end + end + def execute(push_data) return unless supported_events.include?(push_data[:object_kind]) return if project.emails_disabled? + return unless notify_for_ref?(push_data) EmailsOnPushWorker.perform_async( project_id, @@ -35,6 +45,13 @@ class EmailsOnPushService < Service ) end + def notify_for_ref?(push_data) + return true if push_data[:object_kind] == 'tag_push' + return true if push_data.dig(:object_attributes, :tag) + + notify_for_branch?(push_data) + end + def send_from_committer_email? Gitlab::Utils.to_boolean(self.send_from_committer_email) end @@ -50,6 +67,7 @@ class EmailsOnPushService < Service help: s_("EmailsOnPushService|Send notifications from the committer's email address if the domain is part of the domain GitLab is running on (e.g. %{domains}).") % { domains: domains } }, { type: 'checkbox', name: 'disable_diffs', title: s_("EmailsOnPushService|Disable code diffs"), help: s_("EmailsOnPushService|Don't include possibly sensitive code diffs in notification body.") }, + { type: 'select', name: 'branches_to_be_notified', choices: BRANCH_CHOICES }, { type: 'textarea', name: 'recipients', placeholder: s_('EmailsOnPushService|Emails separated by whitespace') } ] end diff --git a/app/models/project_services/external_wiki_service.rb b/app/models/project_services/external_wiki_service.rb index 593ce69b0fdabd95e75b4396e8b4a466cdd8838e..0a09000fff405e5468ff464d9d5b394ddfeeb4de 100644 --- a/app/models/project_services/external_wiki_service.rb +++ b/app/models/project_services/external_wiki_service.rb @@ -19,15 +19,20 @@ class ExternalWikiService < Service def fields [ - { type: 'text', name: 'external_wiki_url', placeholder: s_('ExternalWikiService|The URL of the external Wiki'), required: true } + { + type: 'text', + name: 'external_wiki_url', + placeholder: s_('ExternalWikiService|The URL of the external Wiki'), + required: true + } ] end def execute(_data) - @response = Gitlab::HTTP.get(properties['external_wiki_url'], verify: true) rescue nil - if @response != 200 - nil - end + response = Gitlab::HTTP.get(properties['external_wiki_url'], verify: true) + response.body if response.code == 200 + rescue + nil end def self.supported_events diff --git a/app/models/project_wiki.rb b/app/models/project_wiki.rb index 48c96203921c4198c88cf92e292baa67fab61406..f4666197deff7984e18491d20b4756778cc42692 100644 --- a/app/models/project_wiki.rb +++ b/app/models/project_wiki.rb @@ -7,7 +7,8 @@ class ProjectWiki MARKUPS = { 'Markdown' => :markdown, 'RDoc' => :rdoc, - 'AsciiDoc' => :asciidoc + 'AsciiDoc' => :asciidoc, + 'Org' => :org }.freeze unless defined?(MARKUPS) CouldNotCreateWikiError = Class.new(StandardError) diff --git a/app/models/release.rb b/app/models/release.rb index 4fac64689ab1e357c3a641c90cdcb0574c870c87..ecfae554fe00fe6f30b9ac0692e768d098fc935f 100644 --- a/app/models/release.rb +++ b/app/models/release.rb @@ -3,6 +3,7 @@ class Release < ApplicationRecord include Presentable include CacheMarkdownField + include Importable include Gitlab::Utils::StrongMemoize cache_markdown_field :description @@ -33,8 +34,8 @@ class Release < ApplicationRecord delegate :repository, to: :project - after_commit :create_evidence!, on: :create - after_commit :notify_new_release, on: :create + after_commit :create_evidence!, on: :create, unless: :importing? + after_commit :notify_new_release, on: :create, unless: :importing? MAX_NUMBER_TO_DISPLAY = 3 @@ -81,6 +82,10 @@ class Release < ApplicationRecord evidence&.summary || {} end + def milestone_titles + self.milestones.map {|m| m.title }.sort.join(", ") + end + private def actual_sha diff --git a/app/models/repository.rb b/app/models/repository.rb index 2a67c26d840a89ee3faec50e7f9d5d185c00e1ff..c53b2fc5340c04720e9bec4c773d123d716cdfa1 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -95,7 +95,7 @@ class Repository def path_to_repo @path_to_repo ||= begin - storage = Gitlab.config.repositories.storages[@project.repository_storage] + storage = Gitlab.config.repositories.storages[project.repository_storage] File.expand_path( File.join(storage.legacy_disk_path, disk_path + '.git') @@ -128,7 +128,7 @@ class Repository commits = Gitlab::Git::Commit.batch_by_oid(raw_repository, oids) if commits.present? - Commit.decorate(commits, @project) + Commit.decorate(commits, project) else [] end @@ -159,14 +159,14 @@ class Repository } commits = Gitlab::Git::Commit.where(options) - commits = Commit.decorate(commits, @project) if commits.present? + commits = Commit.decorate(commits, project) if commits.present? CommitCollection.new(project, commits, ref) end def commits_between(from, to) commits = Gitlab::Git::Commit.between(raw_repository, from, to) - commits = Commit.decorate(commits, @project) if commits.present? + commits = Commit.decorate(commits, project) if commits.present? commits end @@ -695,13 +695,13 @@ class Repository commits = raw_repository.list_last_commits_for_tree(sha, path, offset: offset, limit: limit) commits.each do |path, commit| - commits[path] = ::Commit.new(commit, @project) + commits[path] = ::Commit.new(commit, project) end end def last_commit_for_path(sha, path) commit = raw_repository.last_commit_for_path(sha, path) - ::Commit.new(commit, @project) if commit + ::Commit.new(commit, project) if commit end def last_commit_id_for_path(sha, path) @@ -1062,18 +1062,22 @@ class Repository rebase_sha end - def rebase(user, merge_request) + def rebase(user, merge_request, skip_ci: false) if Feature.disabled?(:two_step_rebase, default_enabled: true) return rebase_deprecated(user, merge_request) end + push_options = [] + push_options << Gitlab::PushOptions::CI_SKIP if skip_ci + raw.rebase( user, merge_request.id, branch: merge_request.source_branch, branch_sha: merge_request.source_branch_sha, remote_repository: merge_request.target_project.repository.raw, - remote_branch: merge_request.target_branch + remote_branch: merge_request.target_branch, + push_options: push_options ) do |commit_id| merge_request.update!(rebase_commit_sha: commit_id, merge_error: nil) end @@ -1127,8 +1131,8 @@ class Repository private - # TODO Generice finder, later split this on finders by Ref or Oid - # https://gitlab.com/gitlab-org/gitlab-foss/issues/39239 + # TODO Genericize finder, later split this on finders by Ref or Oid + # https://gitlab.com/gitlab-org/gitlab/issues/19877 def find_commit(oid_or_ref) commit = if oid_or_ref.is_a?(Gitlab::Git::Commit) oid_or_ref @@ -1136,7 +1140,7 @@ class Repository Gitlab::Git::Commit.find(raw_repository, oid_or_ref) end - ::Commit.new(commit, @project) if commit + ::Commit.new(commit, project) if commit end def cache diff --git a/app/models/resource_weight_event.rb b/app/models/resource_weight_event.rb new file mode 100644 index 0000000000000000000000000000000000000000..ab288798aedc5b8cfee0c121fab838cfe7297785 --- /dev/null +++ b/app/models/resource_weight_event.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +class ResourceWeightEvent < ApplicationRecord + include Gitlab::Utils::StrongMemoize + + validates :user, presence: true + validates :issue, presence: true + + belongs_to :user + belongs_to :issue + + scope :by_issue, ->(issue) { where(issue_id: issue.id) } + scope :created_after, ->(time) { where('created_at > ?', time) } + + def discussion_id(resource = nil) + strong_memoize(:discussion_id) do + Digest::SHA1.hexdigest(discussion_id_key.join("-")) + end + end + + private + + def discussion_id_key + [self.class.name, created_at, user_id] + end +end diff --git a/app/models/sentry_issue.rb b/app/models/sentry_issue.rb index 6be52f995621a20fc86676782c778b9431848de2..e60ad6015a506874346a2e581d52e16023c04475 100644 --- a/app/models/sentry_issue.rb +++ b/app/models/sentry_issue.rb @@ -4,7 +4,11 @@ class SentryIssue < ApplicationRecord belongs_to :issue validates :issue, uniqueness: true, presence: true - validates :sentry_issue_identifier, - uniqueness: true, - presence: true + validates :sentry_issue_identifier, presence: true + + def self.for_project_and_identifier(project, identifier) + joins(:issue) + .where(issues: { project_id: project.id }) + .find_by_sentry_issue_identifier(identifier) + end end diff --git a/app/models/snippet.rb b/app/models/snippet.rb index 92746d28f05d78b0bdbe20920db59e9ad7346a30..b3b3de21dee66c356d0ebed147ac699ea7ec265b 100644 --- a/app/models/snippet.rb +++ b/app/models/snippet.rb @@ -14,8 +14,12 @@ class Snippet < ApplicationRecord include Editable include Gitlab::SQL::Pattern include FromUnion + include IgnorableColumns + extend ::Gitlab::Utils::Override + ignore_column :storage_version, remove_with: '12.9', remove_after: '2020-03-22' + cache_markdown_field :title, pipeline: :single_line cache_markdown_field :description cache_markdown_field :content diff --git a/app/models/user.rb b/app/models/user.rb index ee42a98793949512e3d6070a88beac72a03b7f2f..df54f358ffa01e73676cdb4bb0a63e3f6603087d 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -20,6 +20,7 @@ class User < ApplicationRecord include WithUploads include OptionallySearch include FromUnion + include BatchDestroyDependentAssociations DEFAULT_NOTIFICATION_LEVEL = :participating @@ -163,9 +164,9 @@ class User < ApplicationRecord # Validations # # Note: devise :validatable above adds validations for :email and :password - validates :name, presence: true, length: { maximum: 128 } - validates :first_name, length: { maximum: 255 } - validates :last_name, length: { maximum: 255 } + validates :name, presence: true, length: { maximum: 255 } + validates :first_name, length: { maximum: 127 } + validates :last_name, length: { maximum: 127 } validates :email, confirmation: true validates :notification_email, presence: true validates :notification_email, devise_email: true, if: ->(user) { user.notification_email != user.email } @@ -246,6 +247,7 @@ class User < ApplicationRecord delegate :show_whitespace_in_diffs, :show_whitespace_in_diffs=, to: :user_preference delegate :sourcegraph_enabled, :sourcegraph_enabled=, to: :user_preference delegate :setup_for_company, :setup_for_company=, to: :user_preference + delegate :render_whitespace_in_code, :render_whitespace_in_code=, to: :user_preference accepts_nested_attributes_for :user_preference, update_only: true @@ -285,6 +287,10 @@ class User < ApplicationRecord end end + before_transition do + !Gitlab::Database.read_only? + end + # rubocop: disable CodeReuse/ServiceClass # Ideally we should not call a service object here but user.block # is also bcalled by Users::MigrateToGhostUserService which references @@ -301,6 +307,8 @@ class User < ApplicationRecord scope :blocked, -> { with_states(:blocked, :ldap_blocked) } scope :external, -> { where(external: true) } scope :active, -> { with_state(:active).non_internal } + scope :active_without_ghosts, -> { with_state(:active).without_ghosts } + scope :without_ghosts, -> { where('ghost IS NOT TRUE') } scope :deactivated, -> { with_state(:deactivated).non_internal } scope :without_projects, -> { joins('LEFT JOIN project_authorizations ON users.id = project_authorizations.user_id').where(project_authorizations: { user_id: nil }) } scope :order_recent_sign_in, -> { reorder(Gitlab::Database.nulls_last_order('current_sign_in_at', 'DESC')) } @@ -464,7 +472,7 @@ class User < ApplicationRecord when 'deactivated' deactivated else - active + active_without_ghosts end end @@ -608,7 +616,7 @@ class User < ApplicationRecord end def self.non_internal - where('ghost IS NOT TRUE') + without_ghosts end # diff --git a/app/models/user_preference.rb b/app/models/user_preference.rb index a36f56089a07d408be2a9dd2545af049ee90440c..713b0598029e244e32ea060858bd196794ca0510 100644 --- a/app/models/user_preference.rb +++ b/app/models/user_preference.rb @@ -13,6 +13,7 @@ class UserPreference < ApplicationRecord default_value_for :timezone, value: Time.zone.tzinfo.name, allows_nil: false default_value_for :time_display_relative, value: true, allows_nil: false default_value_for :time_format_in_24h, value: false, allows_nil: false + default_value_for :render_whitespace_in_code, value: false, allows_nil: false class << self def notes_filters diff --git a/app/policies/ci/trigger_policy.rb b/app/policies/ci/trigger_policy.rb index 578301d7f7ec8cf69e5eeac82b311cb75e0cc851..e26f96a4b2b11102754aa084b144e77d7425bd46 100644 --- a/app/policies/ci/trigger_policy.rb +++ b/app/policies/ci/trigger_policy.rb @@ -5,13 +5,12 @@ module Ci delegate { @subject.project } with_options scope: :subject, score: 0 - condition(:legacy) { @subject.supports_legacy_tokens? && @subject.legacy? } with_score 0 condition(:is_owner) { @user && @subject.owner_id == @user.id } rule { ~can?(:admin_build) }.prevent :admin_trigger - rule { legacy | is_owner }.enable :admin_trigger + rule { is_owner }.enable :admin_trigger rule { can?(:admin_build) }.enable :manage_trigger end diff --git a/app/policies/grafana_integration_policy.rb b/app/policies/grafana_integration_policy.rb new file mode 100644 index 0000000000000000000000000000000000000000..529a1fe04936bcc0b4e34c23aebb98ed7f6b3fc5 --- /dev/null +++ b/app/policies/grafana_integration_policy.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class GrafanaIntegrationPolicy < BasePolicy + delegate { @subject.project } +end diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb index 7b0297ea81bc0d71bd4241a532aaa8ebdffcf604..e38eef527bec55fda4031d9171eacc3e2cc22500 100644 --- a/app/policies/project_policy.rb +++ b/app/policies/project_policy.rb @@ -83,6 +83,11 @@ class ProjectPolicy < BasePolicy project.merge_requests_allowing_push_to_user(user).any? end + with_scope :subject + condition(:forking_allowed) do + @subject.feature_available?(:forking, @user) + end + with_scope :global condition(:mirror_available, score: 0) do ::Gitlab::CurrentSettings.current_application_settings.mirror_available @@ -203,7 +208,6 @@ class ProjectPolicy < BasePolicy enable :download_code enable :read_statistics enable :download_wiki_code - enable :fork_project enable :create_project_snippet enable :update_issue enable :reopen_issue @@ -232,12 +236,15 @@ class ProjectPolicy < BasePolicy enable :public_access enable :guest_access - enable :fork_project enable :build_download_code enable :build_read_container_image enable :request_access end + rule { (can?(:public_user_access) | can?(:reporter_access)) & forking_allowed }.policy do + enable :fork_project + end + rule { owner | admin | guest | group_member }.prevent :request_access rule { ~request_access_enabled }.prevent :request_access diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb index d092a2de882e232cd41f7eb95a66620e802f905b..43f472b4c1d2c2dbbb1d6357b7d313543ca39ec8 100644 --- a/app/policies/user_policy.rb +++ b/app/policies/user_policy.rb @@ -25,3 +25,5 @@ class UserPolicy < BasePolicy rule { default }.enable :read_user_profile rule { (private_profile | blocked_user) & ~(user_is_self | admin) }.prevent :read_user_profile end + +UserPolicy.prepend_if_ee('EE::UserPolicy') diff --git a/app/presenters/ci/bridge_presenter.rb b/app/presenters/ci/bridge_presenter.rb index ee11cffe3552e790976b34933614c359e4621f29..724e10c26c33ab397687d832e82a20531f266034 100644 --- a/app/presenters/ci/bridge_presenter.rb +++ b/app/presenters/ci/bridge_presenter.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Ci - class BridgePresenter < CommitStatusPresenter + class BridgePresenter < ProcessablePresenter def detailed_status @detailed_status ||= subject.detailed_status(user) end diff --git a/app/presenters/ci/build_presenter.rb b/app/presenters/ci/build_presenter.rb index 33056a809b77373b275a2ce5c3eed839ef4c2abc..03cbb57eb846a15f16b9ed0b6a782dbcf941f37b 100644 --- a/app/presenters/ci/build_presenter.rb +++ b/app/presenters/ci/build_presenter.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Ci - class BuildPresenter < CommitStatusPresenter + class BuildPresenter < ProcessablePresenter def erased_by_user? # Build can be erased through API, therefore it does not have # `erased_by` user assigned in that case. diff --git a/app/presenters/ci/build_runner_presenter.rb b/app/presenters/ci/build_runner_presenter.rb index 8e46979558137b9d5191298d2e64a8606067f52a..33b7899f9122a8b529f9324e710fbae1c9ee39c0 100644 --- a/app/presenters/ci/build_runner_presenter.rb +++ b/app/presenters/ci/build_runner_presenter.rb @@ -34,7 +34,7 @@ module Ci def refspecs specs = [] - specs << refspec_for_pipeline_ref if merge_request_ref? + specs << refspec_for_pipeline_ref if should_expose_merge_request_ref? specs << refspec_for_persistent_ref if persistent_ref_exist? if git_depth > 0 @@ -50,6 +50,19 @@ module Ci private + # We will stop exposing merge request refs when we fully depend on persistent refs + # (i.e. remove `refspec_for_pipeline_ref` when we remove `depend_on_persistent_pipeline_ref` feature flag.) + # `ci_force_exposing_merge_request_refs` is an extra feature flag that allows us to + # forcibly expose MR refs even if the `depend_on_persistent_pipeline_ref` feature flag enabled. + # This is useful when we see an unexpected behaviors/reports from users. + # See https://gitlab.com/gitlab-org/gitlab/issues/35140. + def should_expose_merge_request_ref? + return false unless merge_request_ref? + return true if Feature.enabled?(:ci_force_exposing_merge_request_refs, project) + + Feature.disabled?(:depend_on_persistent_pipeline_ref, project, default_enabled: true) + end + def create_archive(artifacts) return unless artifacts[:untracked] || artifacts[:paths] diff --git a/app/presenters/ci/processable_presenter.rb b/app/presenters/ci/processable_presenter.rb new file mode 100644 index 0000000000000000000000000000000000000000..5a8a6649071139cc330ec289eec440aa948d4039 --- /dev/null +++ b/app/presenters/ci/processable_presenter.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +module Ci + class ProcessablePresenter < CommitStatusPresenter + end +end diff --git a/app/presenters/clusters/cluster_presenter.rb b/app/presenters/clusters/cluster_presenter.rb index 97771d84031f93bd67481b8e0ae65139850fd6a3..3ace27c72d52caef2f17d5ba85f0dee84f3affc1 100644 --- a/app/presenters/clusters/cluster_presenter.rb +++ b/app/presenters/clusters/cluster_presenter.rb @@ -10,11 +10,11 @@ module Clusters # We do not want to show the group path for clusters belonging to the # clusterable, only for the ancestor clusters. - def item_link(clusterable_presenter) + def item_link(clusterable_presenter, *html_options) if cluster.group_type? && clusterable != clusterable_presenter.subject contracted_group_name(cluster.group) + ' / ' + link_to_cluster else - link_to_cluster + link_to_cluster(*html_options) end end @@ -84,8 +84,8 @@ module Clusters sprite_icon('ellipsis_h', size: 12, css_class: 'vertical-align-middle') end - def link_to_cluster - link_to_if(can_read_cluster?, cluster.name, show_path) + def link_to_cluster(html_options: {}) + link_to_if(can_read_cluster?, cluster.name, show_path, html_options) end end end diff --git a/app/presenters/project_presenter.rb b/app/presenters/project_presenter.rb index 81018398d5d361a42747d738c7ceffccfcca811f..8c24d07675a0348ef49b0d7d7cff28360e711ec4 100644 --- a/app/presenters/project_presenter.rb +++ b/app/presenters/project_presenter.rb @@ -24,7 +24,8 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated commits_anchor_data, branches_anchor_data, tags_anchor_data, - files_anchor_data + files_anchor_data, + releases_anchor_data ].compact.select(&:is_link) end @@ -153,6 +154,22 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated empty_repo? ? nil : project_tree_path(project)) end + def releases_anchor_data + return unless can?(current_user, :read_release, project) + + releases_count = project.releases.count + return if releases_count < 1 + + AnchorData.new(true, + statistic_icon('rocket') + + n_('%{strong_start}%{release_count}%{strong_end} Release', '%{strong_start}%{release_count}%{strong_end} Releases', releases_count).html_safe % { + release_count: number_with_delimiter(releases_count), + strong_start: '<strong class="project-stat-value">'.html_safe, + strong_end: '</strong>'.html_safe + }, + project_releases_path(project)) + end + def commits_anchor_data AnchorData.new(true, statistic_icon('commit') + @@ -276,8 +293,7 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated end def kubernetes_cluster_anchor_data - if current_user && can?(current_user, :create_cluster, project) - + if can_instantiate_cluster? if clusters.empty? AnchorData.new(false, statistic_icon + _('Add Kubernetes cluster'), @@ -294,7 +310,7 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated end def gitlab_ci_anchor_data - if current_user && can_current_user_push_code? && repository.gitlab_ci_yml.blank? && !auto_devops_enabled? + if cicd_missing? AnchorData.new(false, statistic_icon + _('Set up CI/CD'), add_ci_yml_path) @@ -326,8 +342,28 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated count_of_extra_topics_not_shown > 0 end + def can_setup_review_app? + strong_memoize(:can_setup_review_app) do + (can_instantiate_cluster? && all_clusters_empty?) || cicd_missing? + end + end + + def all_clusters_empty? + strong_memoize(:all_clusters_empty) do + project.all_clusters.empty? + end + end + private + def cicd_missing? + current_user && can_current_user_push_code? && repository.gitlab_ci_yml.blank? && !auto_devops_enabled? + end + + def can_instantiate_cluster? + current_user && can?(current_user, :create_cluster, project) + end + def filename_path(filename) if blob = repository.public_send(filename) # rubocop:disable GitlabSecurity/PublicSend project_blob_path( diff --git a/app/serializers/build_artifact_entity.rb b/app/serializers/build_artifact_entity.rb index 414f436e76ecc74efe5990ede500f5282352bd83..d1750695523f177c53306966625d11cbce9c6ea1 100644 --- a/app/serializers/build_artifact_entity.rb +++ b/app/serializers/build_artifact_entity.rb @@ -2,6 +2,7 @@ class BuildArtifactEntity < Grape::Entity include RequestAwareEntity + include GitlabRoutingHelper expose :name do |job| job.name @@ -11,15 +12,15 @@ class BuildArtifactEntity < Grape::Entity expose :artifacts_expire_at, as: :expire_at expose :path do |job| - download_project_job_artifacts_path(project, job) + fast_download_project_job_artifacts_path(project, job) end expose :keep_path, if: -> (*) { job.has_expiring_artifacts? } do |job| - keep_project_job_artifacts_path(project, job) + fast_keep_project_job_artifacts_path(project, job) end expose :browse_path do |job| - browse_project_job_artifacts_path(project, job) + fast_browse_project_job_artifacts_path(project, job) end private diff --git a/app/serializers/cluster_application_entity.rb b/app/serializers/cluster_application_entity.rb index 218bdd21e37720605e75e0c077b85c09f5bbed4c..632718df780b3b08c94d55f0d1db5dbc8b2f24b3 100644 --- a/app/serializers/cluster_application_entity.rb +++ b/app/serializers/cluster_application_entity.rb @@ -8,9 +8,9 @@ class ClusterApplicationEntity < Grape::Entity expose :external_ip, if: -> (e, _) { e.respond_to?(:external_ip) } expose :external_hostname, if: -> (e, _) { e.respond_to?(:external_hostname) } expose :hostname, if: -> (e, _) { e.respond_to?(:hostname) } - expose :kibana_hostname, if: -> (e, _) { e.respond_to?(:kibana_hostname) } expose :email, if: -> (e, _) { e.respond_to?(:email) } expose :stack, if: -> (e, _) { e.respond_to?(:stack) } + expose :modsecurity_enabled, if: -> (e, _) { e.respond_to?(:modsecurity_enabled) } expose :update_available?, as: :update_available, if: -> (e, _) { e.respond_to?(:update_available?) } expose :can_uninstall?, as: :can_uninstall end diff --git a/app/serializers/deploy_key_entity.rb b/app/serializers/deploy_key_entity.rb index 2682a47fbaaee7351617df7e7aeb6d29844bbb4b..653316ce4d2f36c4c02505fbf264e20706846944 100644 --- a/app/serializers/deploy_key_entity.rb +++ b/app/serializers/deploy_key_entity.rb @@ -5,6 +5,7 @@ class DeployKeyEntity < Grape::Entity expose :user_id expose :title expose :fingerprint + expose :fingerprint_sha256 expose :destroyed_when_orphaned?, as: :destroyed_when_orphaned expose :almost_orphaned?, as: :almost_orphaned expose :created_at diff --git a/app/serializers/error_tracking/detailed_error_entity.rb b/app/serializers/error_tracking/detailed_error_entity.rb index dd0cac8e4cde83f699c1cdcdcbcf201c113e84c8..d3b38a24316ce50d0aa91d46b56b945922b4ee45 100644 --- a/app/serializers/error_tracking/detailed_error_entity.rb +++ b/app/serializers/error_tracking/detailed_error_entity.rb @@ -8,6 +8,8 @@ module ErrorTracking :external_url, :first_release_last_commit, :first_release_short_version, + :gitlab_commit, + :gitlab_commit_path, :first_seen, :frequency, :gitlab_issue, @@ -21,6 +23,7 @@ module ErrorTracking :project_slug, :short_id, :status, + :tags, :title, :type, :user_count diff --git a/app/serializers/pipeline_details_entity.rb b/app/serializers/pipeline_details_entity.rb index 71589ac8315c9500dc9bb6b07719718278cab6d3..a4ab1d399bc31f49cab6840878911e2a063d8f0b 100644 --- a/app/serializers/pipeline_details_entity.rb +++ b/app/serializers/pipeline_details_entity.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class PipelineDetailsEntity < PipelineEntity + expose :project, using: ProjectEntity + expose :flags do expose :latest?, as: :latest end diff --git a/app/serializers/pipeline_entity.rb b/app/serializers/pipeline_entity.rb index 6b2a1bfe666c90d3d7f12b3ec9d7cf037c13bbd4..ba8f4fffe02d9df1261dcfa9e3491504db28149a 100644 --- a/app/serializers/pipeline_entity.rb +++ b/app/serializers/pipeline_entity.rb @@ -77,6 +77,10 @@ class PipelineEntity < Grape::Entity cancel_project_pipeline_path(pipeline.project, pipeline) end + expose :delete_path, if: -> (*) { can_delete? } do |pipeline| + project_pipeline_path(pipeline.project, pipeline) + end + expose :failed_builds, if: -> (*) { can_retry? }, using: JobEntity do |pipeline| pipeline.failed_builds end @@ -95,6 +99,10 @@ class PipelineEntity < Grape::Entity pipeline.cancelable? end + def can_delete? + can?(request.current_user, :destroy_pipeline, pipeline) + end + def has_presentable_merge_request? pipeline.triggered_by_merge_request? && can?(request.current_user, :read_merge_request, pipeline.merge_request) diff --git a/app/serializers/pipeline_serializer.rb b/app/serializers/pipeline_serializer.rb index b25a1ea920981a3bfab794d8a48daff8d16987f2..be535a5d414c00f99949632d5f41634d594dea85 100644 --- a/app/serializers/pipeline_serializer.rb +++ b/app/serializers/pipeline_serializer.rb @@ -41,6 +41,7 @@ class PipelineSerializer < BaseSerializer def preloaded_relations [ :latest_statuses_ordered_by_stage, + :project, :stages, { failed_builds: %i(project metadata) diff --git a/app/serializers/review_app_setup_entity.rb b/app/serializers/review_app_setup_entity.rb new file mode 100644 index 0000000000000000000000000000000000000000..3a21fe24d9e2bc5e09f1328c658794e72017bfea --- /dev/null +++ b/app/serializers/review_app_setup_entity.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class ReviewAppSetupEntity < Grape::Entity + include RequestAwareEntity + + expose :can_setup_review_app?, as: :can_setup_review_app + + expose :all_clusters_empty?, as: :all_clusters_empty, if: -> (_, _) { project.can_setup_review_app? } do |project| + project.all_clusters_empty? + end + + expose :review_snippet, if: -> (_, _) { project.can_setup_review_app? } do |_| + YAML.safe_load(File.read(Rails.root.join('lib', 'gitlab', 'ci', 'snippets', 'review_app_default.yml'))).to_s + end + + private + + def current_user + request.current_user + end + + def project + object + end +end diff --git a/app/serializers/review_app_setup_serializer.rb b/app/serializers/review_app_setup_serializer.rb new file mode 100644 index 0000000000000000000000000000000000000000..4baec7679b071fbaa11485323745b18434929031 --- /dev/null +++ b/app/serializers/review_app_setup_serializer.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class ReviewAppSetupSerializer < BaseSerializer + entity ReviewAppSetupEntity +end diff --git a/app/serializers/suggestion_entity.rb b/app/serializers/suggestion_entity.rb index 2dd62e19e29468edc0b6fd117e7f0c2cb6ab6d6b..4fb19fbc07473d53b65b2eb889ada39e07729604 100644 --- a/app/serializers/suggestion_entity.rb +++ b/app/serializers/suggestion_entity.rb @@ -4,7 +4,9 @@ class SuggestionEntity < API::Entities::Suggestion include RequestAwareEntity unexpose :from_line, :to_line, :from_content, :to_content - expose :diff_lines, using: DiffLineEntity + expose :diff_lines, using: DiffLineEntity do |suggestion| + Gitlab::Diff::Highlight.new(suggestion.diff_lines).highlight + end expose :current_user do expose :can_apply do |suggestion| Ability.allowed?(current_user, :apply_suggestion, suggestion) diff --git a/app/services/akismet_service.rb b/app/services/akismet_service.rb index 63be3c371ecea9adf363fc12b1d26f34efe2f61d..d8098c4a8f5bf130515ccfa5c14408950077cfbf 100644 --- a/app/services/akismet_service.rb +++ b/app/services/akismet_service.rb @@ -1,10 +1,11 @@ # frozen_string_literal: true class AkismetService - attr_accessor :owner, :text, :options + attr_accessor :text, :options - def initialize(owner, text, options = {}) - @owner = owner + def initialize(owner_name, owner_email, text, options = {}) + @owner_name = owner_name + @owner_email = owner_email @text = text @options = options end @@ -16,8 +17,8 @@ class AkismetService type: 'comment', text: text, created_at: DateTime.now, - author: owner.name, - author_email: owner.email, + author: owner_name, + author_email: owner_email, referrer: options[:referrer] } @@ -40,6 +41,8 @@ class AkismetService private + attr_accessor :owner_name, :owner_email + def akismet_client @akismet_client ||= ::Akismet::Client.new(Gitlab::CurrentSettings.akismet_api_key, Gitlab.config.gitlab.url) @@ -55,8 +58,8 @@ class AkismetService params = { type: 'comment', text: text, - author: owner.name, - author_email: owner.email + author: owner_name, + author_email: owner_email } begin diff --git a/app/services/boards/issues/list_service.rb b/app/services/boards/issues/list_service.rb index 37a74cd1b0008546e0d613ca9f40c6f1074d4c20..a9240e1d8a04dc172f0137bc8ffc83e3a5e85744 100644 --- a/app/services/boards/issues/list_service.rb +++ b/app/services/boards/issues/list_service.rb @@ -5,6 +5,10 @@ module Boards class ListService < Boards::BaseService include Gitlab::Utils::StrongMemoize + def self.valid_params + IssuesFinder.valid_params + end + def execute fetch_issues.order_by_position_and_priority end diff --git a/app/services/boards/list_service.rb b/app/services/boards/list_service.rb index 44d5a21b15f7f80171bfded817a1eda99ff3dd1c..8258d5d07d380fcda62f27b6696eaf7c4a54a8c5 100644 --- a/app/services/boards/list_service.rb +++ b/app/services/boards/list_service.rb @@ -4,13 +4,24 @@ module Boards class ListService < Boards::BaseService def execute create_board! if parent.boards.empty? - boards + + if parent.multiple_issue_boards_available? + boards + else + # When multiple issue boards are not available + # a user is only allowed to view the default shown board + first_board + end end private def boards - parent.boards + parent.boards.order_by_name_asc + end + + def first_board + parent.boards.first_board end def create_board! @@ -18,5 +29,3 @@ module Boards end end end - -Boards::ListService.prepend_if_ee('EE::Boards::ListService') diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb index ce3a9eb07723a19754a85686b7c78ef8c7f31846..2daf3a512357265f2896432f6ea86b720ebacff6 100644 --- a/app/services/ci/create_pipeline_service.rb +++ b/app/services/ci/create_pipeline_service.rb @@ -23,7 +23,7 @@ module Ci Gitlab::Ci::Pipeline::Chain::Limit::JobActivity].freeze # rubocop: disable Metrics/ParameterLists - def execute(source, ignore_skip_ci: false, save_on_errors: true, trigger_request: nil, schedule: nil, merge_request: nil, external_pull_request: nil, **options, &block) + def execute(source, ignore_skip_ci: false, save_on_errors: true, trigger_request: nil, schedule: nil, merge_request: nil, external_pull_request: nil, bridge: nil, **options, &block) @pipeline = Ci::Pipeline.new command = Gitlab::Ci::Pipeline::Chain::Command.new( @@ -46,6 +46,7 @@ module Ci current_user: current_user, push_options: params[:push_options] || {}, chat_data: params[:chat_data], + bridge: bridge, **extra_options(options)) sequence = Gitlab::Ci::Pipeline::Chain::Sequence @@ -104,14 +105,14 @@ module Ci if Feature.enabled?(:ci_support_interruptible_pipelines, project, default_enabled: true) project.ci_pipelines .where(ref: pipeline.ref) - .where.not(id: pipeline.id) + .where.not(id: pipeline.same_family_pipeline_ids) .where.not(sha: project.commit(pipeline.ref).try(:id)) .alive_or_scheduled .with_only_interruptible_builds else project.ci_pipelines .where(ref: pipeline.ref) - .where.not(id: pipeline.id) + .where.not(id: pipeline.same_family_pipeline_ids) .where.not(sha: project.commit(pipeline.ref).try(:id)) .created_or_pending end diff --git a/app/services/ci/pipeline_processing/atomic_processing_service.rb b/app/services/ci/pipeline_processing/atomic_processing_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..1ed295f595039da31f819ce2e739fea732cfecf8 --- /dev/null +++ b/app/services/ci/pipeline_processing/atomic_processing_service.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +module Ci + module PipelineProcessing + class AtomicProcessingService + include Gitlab::Utils::StrongMemoize + include ExclusiveLeaseGuard + + attr_reader :pipeline + + DEFAULT_LEASE_TIMEOUT = 1.minute + BATCH_SIZE = 20 + + def initialize(pipeline) + @pipeline = pipeline + @collection = AtomicProcessingService::StatusCollection.new(pipeline) + end + + def execute + return unless pipeline.needs_processing? + + success = try_obtain_lease { process! } + + # re-schedule if we need further processing + if success && pipeline.needs_processing? + PipelineProcessWorker.perform_async(pipeline.id) + end + + success + end + + private + + def process! + update_stages! + update_pipeline! + update_statuses_processed! + + true + end + + def update_stages! + pipeline.stages.ordered.each(&method(:update_stage!)) + end + + def update_stage!(stage) + # Update processables for a given stage in bulk/slices + ids = @collection.created_processable_ids_for_stage_position(stage.position) + ids.in_groups_of(BATCH_SIZE, false, &method(:update_processables!)) + + status = @collection.status_for_stage_position(stage.position) + stage.set_status(status) + end + + def update_processables!(ids) + created_processables = pipeline.processables.for_ids(ids) + .with_project_preload + .created + .latest + .ordered_by_stage + .select_with_aggregated_needs(project) + + created_processables.each(&method(:update_processable!)) + end + + def update_pipeline! + pipeline.set_status(@collection.status_of_all) + end + + def update_statuses_processed! + processing = @collection.processing_processables + processing.each_slice(BATCH_SIZE) do |slice| + pipeline.statuses.match_id_and_lock_version(slice) + .update_as_processed! + end + end + + def update_processable!(processable) + status = processable_status(processable) + return unless HasStatus::COMPLETED_STATUSES.include?(status) + + # transition status if possible + Gitlab::OptimisticLocking.retry_lock(processable) do |subject| + Ci::ProcessBuildService.new(project, subject.user) + .execute(subject, status) + + # update internal representation of status + # to make the status change of processable + # to be taken into account during further processing + @collection.set_processable_status( + processable.id, processable.status, processable.lock_version) + end + end + + def processable_status(processable) + if needs_names = processable.aggregated_needs_names + # Processable uses DAG, get status of all dependent needs + @collection.status_for_names(needs_names) + else + # Processable uses Stages, get status of prior stage + @collection.status_for_prior_stage_position(processable.stage_idx.to_i) + end + end + + def project + pipeline.project + end + + def lease_key + "#{super}::pipeline_id:#{pipeline.id}" + end + + def lease_timeout + DEFAULT_LEASE_TIMEOUT + end + end + end +end diff --git a/app/services/ci/pipeline_processing/atomic_processing_service/status_collection.rb b/app/services/ci/pipeline_processing/atomic_processing_service/status_collection.rb new file mode 100644 index 0000000000000000000000000000000000000000..42e38a5c80f0dd6723f0f77a6b29ff326d31431b --- /dev/null +++ b/app/services/ci/pipeline_processing/atomic_processing_service/status_collection.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +module Ci + module PipelineProcessing + class AtomicProcessingService + class StatusCollection + include Gitlab::Utils::StrongMemoize + + attr_reader :pipeline + + # We use these columns to perform an efficient + # calculation of a status + STATUSES_COLUMNS = [ + :id, :name, :status, :allow_failure, + :stage_idx, :processed, :lock_version + ].freeze + + def initialize(pipeline) + @pipeline = pipeline + @stage_statuses = {} + @prior_stage_statuses = {} + end + + # This method updates internal status for given ID + def set_processable_status(id, status, lock_version) + processable = all_statuses_by_id[id] + return unless processable + + processable[:status] = status + processable[:lock_version] = lock_version + end + + # This methods gets composite status of all processables + def status_of_all + status_for_array(all_statuses) + end + + # This methods gets composite status for processables with given names + def status_for_names(names) + name_statuses = all_statuses_by_name.slice(*names) + + status_for_array(name_statuses.values) + end + + # This methods gets composite status for processables before given stage + def status_for_prior_stage_position(position) + strong_memoize("status_for_prior_stage_position_#{position}") do + stage_statuses = all_statuses_grouped_by_stage_position + .select { |stage_position, _| stage_position < position } + + status_for_array(stage_statuses.values.flatten) + end + end + + # This methods gets a list of processables for a given stage + def created_processable_ids_for_stage_position(current_position) + all_statuses_grouped_by_stage_position[current_position] + .to_a + .select { |processable| processable[:status] == 'created' } + .map { |processable| processable[:id] } + end + + # This methods gets composite status for processables at a given stage + def status_for_stage_position(current_position) + strong_memoize("status_for_stage_position_#{current_position}") do + stage_statuses = all_statuses_grouped_by_stage_position[current_position].to_a + + status_for_array(stage_statuses.flatten) + end + end + + # This method returns a list of all processable, that are to be processed + def processing_processables + all_statuses.lazy.reject { |status| status[:processed] } + end + + private + + def status_for_array(statuses) + result = Gitlab::Ci::Status::Composite + .new(statuses) + .status + result || 'success' + end + + def all_statuses_grouped_by_stage_position + strong_memoize(:all_statuses_by_order) do + all_statuses.group_by { |status| status[:stage_idx].to_i } + end + end + + def all_statuses_by_id + strong_memoize(:all_statuses_by_id) do + all_statuses.map do |row| + [row[:id], row] + end.to_h + end + end + + def all_statuses_by_name + strong_memoize(:statuses_by_name) do + all_statuses.map do |row| + [row[:name], row] + end.to_h + end + end + + # rubocop: disable CodeReuse/ActiveRecord + def all_statuses + # We fetch all relevant data in one go. + # + # This is more efficient than relying + # on PostgreSQL to calculate composite status + # for us + # + # Since we need to reprocess everything + # we can fetch all of them and do processing + # ourselves. + strong_memoize(:all_statuses) do + raw_statuses = pipeline + .statuses + .latest + .ordered_by_stage + .pluck(*STATUSES_COLUMNS) + + raw_statuses.map do |row| + STATUSES_COLUMNS.zip(row).to_h + end + end + end + # rubocop: enable CodeReuse/ActiveRecord + end + end + end +end diff --git a/app/services/ci/pipeline_processing/legacy_processing_service.rb b/app/services/ci/pipeline_processing/legacy_processing_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..400dc9f0abb50efccbd4cf252abe30b2b7e09175 --- /dev/null +++ b/app/services/ci/pipeline_processing/legacy_processing_service.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +module Ci + module PipelineProcessing + class LegacyProcessingService + include Gitlab::Utils::StrongMemoize + + attr_reader :pipeline + + def initialize(pipeline) + @pipeline = pipeline + end + + def execute(trigger_build_ids = nil) + success = process_stages_without_needs + + # we evaluate dependent needs, + # only when the another job has finished + success = process_builds_with_needs(trigger_build_ids) || success + + @pipeline.update_legacy_status + + success + end + + private + + def process_stages_without_needs + stage_indexes_of_created_processables_without_needs.flat_map do |index| + process_stage_without_needs(index) + end.any? + end + + def process_stage_without_needs(index) + current_status = status_for_prior_stages(index) + + return unless HasStatus::COMPLETED_STATUSES.include?(current_status) + + created_processables_in_stage_without_needs(index).find_each.select do |build| + process_build(build, current_status) + end.any? + end + + def process_builds_with_needs(trigger_build_ids) + return false unless trigger_build_ids.present? + return false unless Feature.enabled?(:ci_dag_support, project, default_enabled: true) + + # we find processables that are dependent: + # 1. because of current dependency, + trigger_build_names = pipeline.processables.latest + .for_ids(trigger_build_ids).names + + # 2. does not have builds that not yet complete + incomplete_build_names = pipeline.processables.latest + .incomplete.names + + # Each found processable is guaranteed here to have completed status + created_processables + .with_needs(trigger_build_names) + .without_needs(incomplete_build_names) + .find_each + .map(&method(:process_build_with_needs)) + .any? + end + + def process_build_with_needs(build) + current_status = status_for_build_needs(build.needs.map(&:name)) + + return unless HasStatus::COMPLETED_STATUSES.include?(current_status) + + process_build(build, current_status) + end + + def process_build(build, current_status) + Gitlab::OptimisticLocking.retry_lock(build) do |subject| + Ci::ProcessBuildService.new(project, subject.user) + .execute(subject, current_status) + end + end + + def status_for_prior_stages(index) + pipeline.processables.status_for_prior_stages(index) + end + + def status_for_build_needs(needs) + pipeline.processables.status_for_names(needs) + end + + # rubocop: disable CodeReuse/ActiveRecord + def stage_indexes_of_created_processables_without_needs + created_processables_without_needs.order(:stage_idx) + .pluck(Arel.sql('DISTINCT stage_idx')) + end + # rubocop: enable CodeReuse/ActiveRecord + + def created_processables_in_stage_without_needs(index) + created_processables_without_needs + .with_preloads + .for_stage(index) + end + + def created_processables_without_needs + if Feature.enabled?(:ci_dag_support, project, default_enabled: true) + pipeline.processables.created.without_needs + else + pipeline.processables.created + end + end + + def created_processables + pipeline.processables.created + end + + def project + pipeline.project + end + end + end +end diff --git a/app/services/ci/prepare_build_service.rb b/app/services/ci/prepare_build_service.rb index 5d024c45e5f5b7b60dd9d1feea9c385f269f529f..3f87c7112706e1271da1cc6d9cfe00144ea8e391 100644 --- a/app/services/ci/prepare_build_service.rb +++ b/app/services/ci/prepare_build_service.rb @@ -11,7 +11,7 @@ module Ci def execute prerequisites.each(&:complete!) - build.enqueue! + build.enqueue_preparing! rescue => e Gitlab::ErrorTracking.track_exception(e, build_id: build.id) diff --git a/app/services/ci/process_pipeline_service.rb b/app/services/ci/process_pipeline_service.rb index f33cbf7ab290822374bbde4e32d4770750753169..1ecef2562330982a7b79e39f33c5db5982d34103 100644 --- a/app/services/ci/process_pipeline_service.rb +++ b/app/services/ci/process_pipeline_service.rb @@ -2,8 +2,6 @@ module Ci class ProcessPipelineService - include Gitlab::Utils::StrongMemoize - attr_reader :pipeline def initialize(pipeline) @@ -13,104 +11,18 @@ module Ci def execute(trigger_build_ids = nil) update_retried - success = process_stages_without_needs - - # we evaluate dependent needs, - # only when the another job has finished - success = process_builds_with_needs(trigger_build_ids) || success - - @pipeline.update_status - - success - end - - private - - def process_stages_without_needs - stage_indexes_of_created_processables_without_needs.flat_map do |index| - process_stage_without_needs(index) - end.any? - end - - def process_stage_without_needs(index) - current_status = status_for_prior_stages(index) - - return unless HasStatus::COMPLETED_STATUSES.include?(current_status) - - created_processables_in_stage_without_needs(index).find_each.select do |build| - process_build(build, current_status) - end.any? - end - - def process_builds_with_needs(trigger_build_ids) - return false unless trigger_build_ids.present? - return false unless Feature.enabled?(:ci_dag_support, project, default_enabled: true) - - # we find processables that are dependent: - # 1. because of current dependency, - trigger_build_names = pipeline.processables.latest - .for_ids(trigger_build_ids).names - - # 2. does not have builds that not yet complete - incomplete_build_names = pipeline.processables.latest - .incomplete.names - - # Each found processable is guaranteed here to have completed status - created_processables - .with_needs(trigger_build_names) - .without_needs(incomplete_build_names) - .find_each - .map(&method(:process_build_with_needs)) - .any? - end - - def process_build_with_needs(build) - current_status = status_for_build_needs(build.needs.map(&:name)) - - return unless HasStatus::COMPLETED_STATUSES.include?(current_status) - - process_build(build, current_status) - end - - def process_build(build, current_status) - Gitlab::OptimisticLocking.retry_lock(build) do |subject| - Ci::ProcessBuildService.new(project, build.user) - .execute(subject, current_status) - end - end - - def status_for_prior_stages(index) - pipeline.processables.status_for_prior_stages(index) - end - - def status_for_build_needs(needs) - pipeline.processables.status_for_names(needs) - end - - # rubocop: disable CodeReuse/ActiveRecord - def stage_indexes_of_created_processables_without_needs - created_processables_without_needs.order(:stage_idx) - .pluck(Arel.sql('DISTINCT stage_idx')) - end - # rubocop: enable CodeReuse/ActiveRecord - - def created_processables_in_stage_without_needs(index) - created_processables_without_needs - .with_preloads - .for_stage(index) - end - - def created_processables_without_needs - if Feature.enabled?(:ci_dag_support, project, default_enabled: true) - pipeline.processables.created.without_needs + if Feature.enabled?(:ci_atomic_processing, pipeline.project) + Ci::PipelineProcessing::AtomicProcessingService + .new(pipeline) + .execute else - pipeline.processables.created + Ci::PipelineProcessing::LegacyProcessingService + .new(pipeline) + .execute(trigger_build_ids) end end - def created_processables - pipeline.processables.created - end + private # This method is for compatibility and data consistency and should be removed with 9.3 version of GitLab # This replicates what is db/post_migrate/20170416103934_upate_retried_for_ci_build.rb @@ -131,9 +43,5 @@ module Ci .update_all(retried: true) if latest_statuses.any? end # rubocop: enable CodeReuse/ActiveRecord - - def project - pipeline.project - end end end diff --git a/app/services/ci/resource_groups/assign_resource_from_resource_group_service.rb b/app/services/ci/resource_groups/assign_resource_from_resource_group_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..a4bcca8e8b36ee91c0ac375560d70726aef25e0d --- /dev/null +++ b/app/services/ci/resource_groups/assign_resource_from_resource_group_service.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Ci + module ResourceGroups + class AssignResourceFromResourceGroupService < ::BaseService + # rubocop: disable CodeReuse/ActiveRecord + def execute(resource_group) + free_resources = resource_group.resources.free.count + + resource_group.builds.waiting_for_resource.take(free_resources).each do |build| + build.enqueue_waiting_for_resource + end + end + # rubocop: enable CodeReuse/ActiveRecord + end + end +end diff --git a/app/services/ci/retry_build_service.rb b/app/services/ci/retry_build_service.rb index 7a5e33c61babca3e3a19eb6e3d9612859ec25bba..1f00d54b6a7e76344f58fdc52461e0e0d3f1d27d 100644 --- a/app/services/ci/retry_build_service.rb +++ b/app/services/ci/retry_build_service.rb @@ -5,13 +5,13 @@ module Ci CLONE_ACCESSORS = %i[pipeline project ref tag options name allow_failure stage stage_id stage_idx trigger_request yaml_variables when environment coverage_regex - description tag_list protected needs].freeze + description tag_list protected needs resource_group].freeze def execute(build) reprocess!(build).tap do |new_build| build.pipeline.mark_as_processable_after_stage(build.stage_idx) - new_build.enqueue! + Gitlab::OptimisticLocking.retry_lock(new_build, &:enqueue) MergeRequests::AddTodoWhenBuildFailsService .new(project, current_user) @@ -31,15 +31,17 @@ module Ci attributes.push([:user, current_user]) - build.retried = true - Ci::Build.transaction do # mark all other builds of that name as retried build.pipeline.builds.latest .where(name: build.name) - .update_all(retried: true) + .update_all(retried: true, processed: true) - create_build!(attributes) + create_build!(attributes).tap do + # mark existing object as retried/processed without a reload + build.retried = true + build.processed = true + end end end # rubocop: enable CodeReuse/ActiveRecord @@ -49,6 +51,7 @@ module Ci def create_build!(attributes) build = project.builds.new(Hash[attributes]) build.deployment = ::Gitlab::Ci::Pipeline::Seed::Deployment.new(build).to_resource + build.retried = false build.save! build end diff --git a/app/services/clusters/applications/base_service.rb b/app/services/clusters/applications/base_service.rb index c9f7917938fda3bc29879750f9b93455e7d35b3f..844da11e5cb5bea1375185acaa3bc2398934152f 100644 --- a/app/services/clusters/applications/base_service.rb +++ b/app/services/clusters/applications/base_service.rb @@ -19,10 +19,6 @@ module Clusters application.hostname = params[:hostname] end - if application.has_attribute?(:kibana_hostname) - application.kibana_hostname = params[:kibana_hostname] - end - if application.has_attribute?(:email) application.email = params[:email] end @@ -31,6 +27,10 @@ module Clusters application.stack = params[:stack] end + if application.has_attribute?(:modsecurity_enabled) + application.modsecurity_enabled = params[:modsecurity_enabled] || false + end + if application.respond_to?(:oauth_application) application.oauth_application = create_oauth_application(application, request) end @@ -68,7 +68,7 @@ module Clusters end def invalid_application? - unknown_application? || (application_name == Applications::ElasticStack.application_name && !Feature.enabled?(:enable_cluster_application_elastic_stack)) || (application_name == Applications::Crossplane.application_name && !Feature.enabled?(:enable_cluster_application_crossplane)) + unknown_application? || (application_name == Applications::ElasticStack.application_name && !Feature.enabled?(:enable_cluster_application_elastic_stack)) end def unknown_application? diff --git a/app/services/clusters/applications/check_installation_progress_service.rb b/app/services/clusters/applications/check_installation_progress_service.rb index 1ce6e0c1cb0da4c447379b11c78937822fb1eb30..7d064abfaa3e7becb272b23a80d0ce0d17efdc39 100644 --- a/app/services/clusters/applications/check_installation_progress_service.rb +++ b/app/services/clusters/applications/check_installation_progress_service.rb @@ -11,6 +11,8 @@ module Clusters def on_success app.make_installed! + + Gitlab::Tracking.event('cluster:applications', "cluster_application_#{app.name}_installed") ensure remove_installation_pod end diff --git a/app/services/clusters/kubernetes/create_or_update_namespace_service.rb b/app/services/clusters/kubernetes/create_or_update_namespace_service.rb index 15be8446cc08e62d56a0e908112d30b50fecbe56..c6c7eb99bf3d2cf7bab31aa9782d190c4ffab5eb 100644 --- a/app/services/clusters/kubernetes/create_or_update_namespace_service.rb +++ b/app/services/clusters/kubernetes/create_or_update_namespace_service.rb @@ -21,10 +21,15 @@ module Clusters attr_reader :cluster, :kubernetes_namespace, :platform def create_project_service_account + environment_slug = kubernetes_namespace.environment&.slug + namespace_labels = { 'app.gitlab.com/app' => kubernetes_namespace.project.full_path_slug } + namespace_labels['app.gitlab.com/env'] = environment_slug if environment_slug + Clusters::Kubernetes::CreateOrUpdateServiceAccountService.namespace_creator( platform.kubeclient, service_account_name: kubernetes_namespace.service_account_name, service_account_namespace: kubernetes_namespace.namespace, + service_account_namespace_labels: namespace_labels, rbac: platform.rbac? ).execute end diff --git a/app/services/clusters/kubernetes/create_or_update_service_account_service.rb b/app/services/clusters/kubernetes/create_or_update_service_account_service.rb index d798dcdcfd3242fc2a999deebb1ec9e2045b5e8a..b1820474c9df5c55eb991f9c6842e00fbae3932a 100644 --- a/app/services/clusters/kubernetes/create_or_update_service_account_service.rb +++ b/app/services/clusters/kubernetes/create_or_update_service_account_service.rb @@ -3,10 +3,11 @@ module Clusters module Kubernetes class CreateOrUpdateServiceAccountService - def initialize(kubeclient, service_account_name:, service_account_namespace:, token_name:, rbac:, namespace_creator: false, role_binding_name: nil) + def initialize(kubeclient, service_account_name:, service_account_namespace:, service_account_namespace_labels: nil, token_name:, rbac:, namespace_creator: false, role_binding_name: nil) @kubeclient = kubeclient @service_account_name = service_account_name @service_account_namespace = service_account_namespace + @service_account_namespace_labels = service_account_namespace_labels @token_name = token_name @rbac = rbac @namespace_creator = namespace_creator @@ -23,11 +24,12 @@ module Clusters ) end - def self.namespace_creator(kubeclient, service_account_name:, service_account_namespace:, rbac:) + def self.namespace_creator(kubeclient, service_account_name:, service_account_namespace:, service_account_namespace_labels:, rbac:) self.new( kubeclient, service_account_name: service_account_name, service_account_namespace: service_account_namespace, + service_account_namespace_labels: service_account_namespace_labels, token_name: "#{service_account_namespace}-token", rbac: rbac, namespace_creator: true, @@ -55,12 +57,13 @@ module Clusters private - attr_reader :kubeclient, :service_account_name, :service_account_namespace, :token_name, :rbac, :namespace_creator, :role_binding_name + attr_reader :kubeclient, :service_account_name, :service_account_namespace, :service_account_namespace_labels, :token_name, :rbac, :namespace_creator, :role_binding_name def ensure_project_namespace_exists Gitlab::Kubernetes::Namespace.new( service_account_namespace, - kubeclient + kubeclient, + labels: service_account_namespace_labels ).ensure_exists! end diff --git a/app/services/concerns/akismet_methods.rb b/app/services/concerns/akismet_methods.rb new file mode 100644 index 0000000000000000000000000000000000000000..1cbcf0d47b9df26fadd9cf41495eec009509064c --- /dev/null +++ b/app/services/concerns/akismet_methods.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module AkismetMethods + def spammable_owner + @user ||= User.find(spammable_owner_id) + end + + def spammable_owner_id + @owner_id ||= + if spammable.respond_to?(:author_id) + spammable.author_id + elsif spammable.respond_to?(:creator_id) + spammable.creator_id + end + end + + def akismet + @akismet ||= AkismetService.new( + spammable_owner.name, + spammable_owner.email, + spammable.spammable_text, + options + ) + end +end diff --git a/app/services/spam_check_service.rb b/app/services/concerns/spam_check_methods.rb similarity index 90% rename from app/services/spam_check_service.rb rename to app/services/concerns/spam_check_methods.rb index 51d300d4f1d870420ba9b46e7ef5e4fd98e76530..75d9759f1d19c68bed07f5533cbbe8af751aa763 100644 --- a/app/services/spam_check_service.rb +++ b/app/services/concerns/spam_check_methods.rb @@ -1,14 +1,14 @@ # frozen_string_literal: true -# SpamCheckService +# SpamCheckMethods # # Provide helper methods for checking if a given spammable object has # potential spam data. # # Dependencies: # - params with :request -# -module SpamCheckService + +module SpamCheckMethods # rubocop:disable Gitlab/ModuleWithInstanceVariables def filter_spam_check_params @request = params.delete(:request) @@ -24,7 +24,7 @@ module SpamCheckService # rubocop:disable Gitlab/ModuleWithInstanceVariables # rubocop: disable CodeReuse/ActiveRecord def spam_check(spammable, user) - spam_service = SpamService.new(spammable, @request) + spam_service = SpamService.new(spammable: spammable, request: @request) spam_service.when_recaptcha_verified(@recaptcha_verified, @api) do user.spam_logs.find_by(id: @spam_log_id)&.update!(recaptcha_verified: true) diff --git a/app/services/container_expiration_policy_service.rb b/app/services/container_expiration_policy_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..5d141d4d64d4c2692f8b63394e0e4f7f3f9cf8ad --- /dev/null +++ b/app/services/container_expiration_policy_service.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class ContainerExpirationPolicyService < BaseService + def execute(container_expiration_policy) + container_expiration_policy.schedule_next_run! + + container_expiration_policy.container_repositories.find_each do |container_repository| + CleanupContainerRepositoryWorker.perform_async( + current_user.id, + container_repository.id, + container_expiration_policy.attributes.except("created_at", "updated_at") + ) + end + end +end diff --git a/app/services/create_snippet_service.rb b/app/services/create_snippet_service.rb deleted file mode 100644 index eacea7d94c7f577e9e8a9f56bb1bcc1dfd5a0eb3..0000000000000000000000000000000000000000 --- a/app/services/create_snippet_service.rb +++ /dev/null @@ -1,35 +0,0 @@ -# frozen_string_literal: true - -class CreateSnippetService < BaseService - include SpamCheckService - - def execute - filter_spam_check_params - - snippet = if project - project.snippets.build(params) - else - PersonalSnippet.new(params) - end - - unless Gitlab::VisibilityLevel.allowed_for?(current_user, snippet.visibility_level) - deny_visibility_level(snippet) - return snippet - end - - snippet.author = current_user - - spam_check(snippet, current_user) - - snippet_saved = snippet.with_transaction_returning_status do - snippet.save && snippet.store_mentions! - end - - if snippet_saved - UserAgentDetailService.new(snippet, @request).create - Gitlab::UsageDataCounters::SnippetCounter.count(:create) - end - - snippet - end -end diff --git a/app/services/deployments/after_create_service.rb b/app/services/deployments/after_create_service.rb index 1d9cb666cff3d2f1bc102dc8f30b0f7125ca1fd3..3560f9c983b546f036f9da4cb821c44184b6e04b 100644 --- a/app/services/deployments/after_create_service.rb +++ b/app/services/deployments/after_create_service.rb @@ -34,21 +34,12 @@ module Deployments if environment.save && !environment.stopped? deployment.update_merge_request_metrics! - link_merge_requests(deployment) end end end private - def link_merge_requests(deployment) - unless Feature.enabled?(:deployment_merge_requests, deployment.project) - return - end - - LinkMergeRequestsService.new(deployment).execute - end - def environment_options options&.dig(:environment) || {} end diff --git a/app/services/deployments/link_merge_requests_service.rb b/app/services/deployments/link_merge_requests_service.rb index 71186659290129c906eb90721957528dfc6fdaad..a1d6d50bbb452706d4bff28b179105d073d35de8 100644 --- a/app/services/deployments/link_merge_requests_service.rb +++ b/app/services/deployments/link_merge_requests_service.rb @@ -13,7 +13,10 @@ module Deployments end def execute - return unless deployment.success? + # Review apps have the environment type set (e.g. to `review`, though the + # exact value may differ). We don't want to link merge requests to review + # app deployments, as this is not useful. + return if deployment.environment.environment_type if (prev = deployment.previous_environment_deployment) link_merge_requests_for_range(prev.sha, deployment.sha) diff --git a/app/services/error_tracking/issue_update_service.rb b/app/services/error_tracking/issue_update_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..e433b4a11f2b22d7198716efac26cb86ea2b7b9c --- /dev/null +++ b/app/services/error_tracking/issue_update_service.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module ErrorTracking + class IssueUpdateService < ErrorTracking::BaseService + private + + def fetch + project_error_tracking_setting.update_issue( + issue_id: params[:issue_id], + params: update_params + ) + end + + def update_params + params.except(:issue_id) + end + + def parse_response(response) + { updated: response[:updated].present? } + end + end +end diff --git a/app/services/event_create_service.rb b/app/services/event_create_service.rb index f7282c22a5203aefcd23b9d62a8c7d59225e60c2..7460f0df5350d689338f0bb385f95abfb5615bd8 100644 --- a/app/services/event_create_service.rb +++ b/app/services/event_create_service.rb @@ -101,7 +101,7 @@ class EventCreateService Users::LastPushEventService.new(current_user) .cache_last_push_event(event) - Users::ActivityService.new(current_user, 'push').execute + Users::ActivityService.new(current_user).execute end def create_event(resource_parent, current_user, status, attributes = {}) diff --git a/app/services/git/base_hooks_service.rb b/app/services/git/base_hooks_service.rb index d935d9e8cdcb7a0540049c16a6370992876457a9..a49983a84fc2f0747eb59a3b61be147c8308fd2c 100644 --- a/app/services/git/base_hooks_service.rb +++ b/app/services/git/base_hooks_service.rb @@ -163,7 +163,7 @@ module Git end def logger - if Sidekiq.server? + if Gitlab::Runtime.sidekiq? Sidekiq.logger else # This service runs in Sidekiq, so this shouldn't ever be diff --git a/app/services/ham_service.rb b/app/services/ham_service.rb index 794eb34d9ca32c3a859f9bbd6011b9a5f8ff60a0..0bbdaa47a1b513cf2beb18fa1721321cf0a92ce4 100644 --- a/app/services/ham_service.rb +++ b/app/services/ham_service.rb @@ -18,8 +18,10 @@ class HamService private def akismet + user = spam_log.user @akismet ||= AkismetService.new( - spam_log.user, + user.name, + user.email, spam_log.text, ip_address: spam_log.source_ip, user_agent: spam_log.user_agent diff --git a/app/services/issuable/clone/attributes_rewriter.rb b/app/services/issuable/clone/attributes_rewriter.rb index 1f5d83917cc606b8d8d2230877c4c70453605cff..334e50c0be5e65dbc2279ecee76a57d591dc48dd 100644 --- a/app/services/issuable/clone/attributes_rewriter.rb +++ b/app/services/issuable/clone/attributes_rewriter.rb @@ -18,6 +18,7 @@ module Issuable new_entity.update(update_attributes) copy_resource_label_events + copy_resource_weight_events end private @@ -60,6 +61,20 @@ module Issuable end end + def copy_resource_weight_events + return unless original_entity.respond_to?(:resource_weight_events) + + original_entity.resource_weight_events.find_in_batches do |batch| + events = batch.map do |event| + event.attributes + .except('id', 'reference', 'reference_html') + .merge('issue_id' => new_entity.id) + end + + Gitlab::Database.bulk_insert(ResourceWeightEvent.table_name, events) + end + end + def entity_key new_entity.class.name.parameterize('_').foreign_key end diff --git a/app/services/issues/create_service.rb b/app/services/issues/create_service.rb index 8d1df0d87a7136effec81143d59c0b13a024ce4e..e8879d4df66ecff7ae8a43d32f767db5039db385 100644 --- a/app/services/issues/create_service.rb +++ b/app/services/issues/create_service.rb @@ -2,7 +2,7 @@ module Issues class CreateService < Issues::BaseService - include SpamCheckService + include SpamCheckMethods include ResolveDiscussions def execute diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb index b98a4d2567f4849d62079e3a5b390f32ae87ad45..68d1657d881ee67825863725cdb700045dd32bca 100644 --- a/app/services/issues/update_service.rb +++ b/app/services/issues/update_service.rb @@ -2,7 +2,7 @@ module Issues class UpdateService < Issues::BaseService - include SpamCheckService + include SpamCheckMethods def execute(issue) handle_move_between_ids(issue) diff --git a/app/services/members/update_service.rb b/app/services/members/update_service.rb index fdd2c62a45228f68ec7279fd761b739c049e084a..b5c27caafa23a522ad7d98fc9d647775cbe8aa28 100644 --- a/app/services/members/update_service.rb +++ b/app/services/members/update_service.rb @@ -7,9 +7,10 @@ module Members raise Gitlab::Access::AccessDeniedError unless can?(current_user, action_member_permission(permission, member), member) old_access_level = member.human_access + old_expiry = member.expires_at if member.update(params) - after_execute(action: permission, old_access_level: old_access_level, member: member) + after_execute(action: permission, old_access_level: old_access_level, old_expiry: old_expiry, member: member) # Deletes only confidential issues todos for guests enqueue_delete_todos(member) if downgrading_to_guest? diff --git a/app/services/merge_requests/get_urls_service.rb b/app/services/merge_requests/get_urls_service.rb index 7c88c9abb41100b389908106fbcac62192913db8..de3f2acdf6368e2ec797bd7ffc28e35c324dddf4 100644 --- a/app/services/merge_requests/get_urls_service.rb +++ b/app/services/merge_requests/get_urls_service.rb @@ -9,7 +9,7 @@ module MergeRequests end def execute(changes) - return [] unless project.printing_merge_request_link_enabled + return [] unless project&.printing_merge_request_link_enabled branches = get_branches(changes) merge_requests_map = opened_merge_requests_from_source_branches(branches) diff --git a/app/services/merge_requests/rebase_service.rb b/app/services/merge_requests/rebase_service.rb index 7e9442c0c7cca995b6c82fe90f2fee498ea1cf12..bc1e97088af913f741e1d39463c7bec6feff458c 100644 --- a/app/services/merge_requests/rebase_service.rb +++ b/app/services/merge_requests/rebase_service.rb @@ -8,8 +8,9 @@ module MergeRequests attr_reader :merge_request - def execute(merge_request) + def execute(merge_request, skip_ci: false) @merge_request = merge_request + @skip_ci = skip_ci if rebase success @@ -25,7 +26,7 @@ module MergeRequests return false end - repository.rebase(current_user, merge_request) + repository.rebase(current_user, merge_request, skip_ci: @skip_ci) true rescue => e diff --git a/app/services/metrics/dashboard/clone_dashboard_service.rb b/app/services/metrics/dashboard/clone_dashboard_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..b2ec44cb814c60d51e70e784c3c57b00f484c84e --- /dev/null +++ b/app/services/metrics/dashboard/clone_dashboard_service.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +# Copies system dashboard definition in .yml file into designated +# .yml file inside `.gitlab/dashboards` +module Metrics + module Dashboard + class CloneDashboardService < ::BaseService + ALLOWED_FILE_TYPE = '.yml' + USER_DASHBOARDS_DIR = ::Metrics::Dashboard::ProjectDashboardService::DASHBOARD_ROOT + + def self.allowed_dashboard_templates + @allowed_dashboard_templates ||= Set[::Metrics::Dashboard::SystemDashboardService::DASHBOARD_PATH].freeze + end + + def execute + catch(:error) do + throw(:error, error(_(%q(You can't commit to this project)), :forbidden)) unless push_authorized? + + result = ::Files::CreateService.new(project, current_user, dashboard_attrs).execute + throw(:error, wrap_error(result)) unless result[:status] == :success + + repository.refresh_method_caches([:metrics_dashboard]) + success(result.merge(http_status: :created, dashboard: dashboard_details)) + end + end + + private + + def dashboard_attrs + { + commit_message: params[:commit_message], + file_path: new_dashboard_path, + file_content: new_dashboard_content, + encoding: 'text', + branch_name: branch, + start_branch: repository.branch_exists?(branch) ? branch : project.default_branch + } + end + + def dashboard_details + { + path: new_dashboard_path, + display_name: ::Metrics::Dashboard::ProjectDashboardService.name_for_path(new_dashboard_path), + default: false, + system_dashboard: false + } + end + + def push_authorized? + Gitlab::UserAccess.new(current_user, project: project).can_push_to_branch?(branch) + end + + def dashboard_template + @dashboard_template ||= begin + throw(:error, error(_('Not found.'), :not_found)) unless self.class.allowed_dashboard_templates.include?(params[:dashboard]) + + params[:dashboard] + end + end + + def branch + @branch ||= begin + throw(:error, error(_('There was an error creating the dashboard, branch name is invalid.'), :bad_request)) unless valid_branch_name? + throw(:error, error(_('There was an error creating the dashboard, branch named: %{branch} already exists.') % { branch: params[:branch] }, :bad_request)) unless new_or_default_branch? # temporary validation for first UI iteration + + params[:branch] + end + end + + def new_or_default_branch? + !repository.branch_exists?(params[:branch]) || project.default_branch == params[:branch] + end + + def valid_branch_name? + Gitlab::GitRefValidator.validate(params[:branch]) + end + + def new_dashboard_path + @new_dashboard_path ||= File.join(USER_DASHBOARDS_DIR, file_name) + end + + def file_name + @file_name ||= begin + throw(:error, error(_('The file name should have a .yml extension'), :bad_request)) unless target_file_type_valid? + + File.basename(params[:file_name]) + end + end + + def target_file_type_valid? + File.extname(params[:file_name]) == ALLOWED_FILE_TYPE + end + + def new_dashboard_content + File.read(Rails.root.join(dashboard_template)) + end + + def repository + @repository ||= project.repository + end + + def wrap_error(result) + if result[:message] == 'A file with this name already exists' + error(_("A file with '%{file_name}' already exists in %{branch} branch") % { file_name: file_name, branch: branch }, :bad_request) + else + result + end + end + end + end +end + +Metrics::Dashboard::CloneDashboardService.prepend_if_ee('EE::Metrics::Dashboard::CloneDashboardService') diff --git a/app/services/metrics/sample_metrics_service.rb b/app/services/metrics/sample_metrics_service.rb index 719bc6614e42f13346f208741def9b8949754888..9bf32b295e2e1a5ff919f727496ca712583cfa08 100644 --- a/app/services/metrics/sample_metrics_service.rb +++ b/app/services/metrics/sample_metrics_service.rb @@ -4,16 +4,17 @@ module Metrics class SampleMetricsService DIRECTORY = "sample_metrics" - attr_reader :identifier + attr_reader :identifier, :range_minutes - def initialize(identifier) + def initialize(identifier, range_start:, range_end:) @identifier = identifier + @range_minutes = convert_range_minutes(range_start, range_end) end def query return unless identifier && File.exist?(file_location) - YAML.load_file(File.expand_path(file_location, __dir__)) + query_interval end private @@ -22,5 +23,14 @@ module Metrics sanitized_string = identifier.gsub(/[^0-9A-Za-z_]/, '') File.join(Rails.root, DIRECTORY, "#{sanitized_string}.yml") end + + def query_interval + result = YAML.load_file(File.expand_path(file_location, __dir__)) + result[range_minutes] + end + + def convert_range_minutes(range_start, range_end) + ((range_end.to_time - range_start.to_time) / 1.minute).to_i + end end end diff --git a/app/services/notes/create_service.rb b/app/services/notes/create_service.rb index accfdb5b863b27dee0057b37f1f690e588bcdb24..50dc98b88e9e83bfd87a813d21566379bc0bc7b7 100644 --- a/app/services/notes/create_service.rb +++ b/app/services/notes/create_service.rb @@ -4,9 +4,7 @@ module Notes class CreateService < ::Notes::BaseService # rubocop:disable Metrics/CyclomaticComplexity def execute - merge_request_diff_head_sha = params.delete(:merge_request_diff_head_sha) - - note = Notes::BuildService.new(project, current_user, params).execute + note = Notes::BuildService.new(project, current_user, params.except(:merge_request_diff_head_sha)).execute # n+1: https://gitlab.com/gitlab-org/gitlab-foss/issues/37440 note_valid = Gitlab::GitalyClient.allow_n_plus_1_calls do @@ -23,8 +21,7 @@ module Notes quick_actions_service = QuickActionsService.new(project, current_user) if quick_actions_service.supported?(note) - options = { merge_request_diff_head_sha: merge_request_diff_head_sha } - content, update_params, message = quick_actions_service.execute(note, options) + content, update_params, message = quick_actions_service.execute(note, quick_action_options) only_commands = content.empty? @@ -74,6 +71,11 @@ module Notes private + # EE::Notes::CreateService would override this method + def quick_action_options + { merge_request_diff_head_sha: params[:merge_request_diff_head_sha] } + end + def tracking_data_for(note) label = Gitlab.ee? && note.author == User.visual_review_bot ? 'anonymous_visual_review_note' : 'note' @@ -84,3 +86,5 @@ module Notes end end end + +Notes::CreateService.prepend_if_ee('EE::Notes::CreateService') diff --git a/app/services/notes/destroy_service.rb b/app/services/notes/destroy_service.rb index fa0c2c5c86b315a35ca5e85c7c66c1ab4cf6f1b8..ee8a680fcb4916c7163dae2284c417c0c7646feb 100644 --- a/app/services/notes/destroy_service.rb +++ b/app/services/notes/destroy_service.rb @@ -11,3 +11,5 @@ module Notes end end end + +Notes::DestroyService.prepend_if_ee('EE::Notes::DestroyService') diff --git a/app/services/pages_domains/create_acme_order_service.rb b/app/services/pages_domains/create_acme_order_service.rb index c600f497fa5ac4f50509a659e63d6f46739b0f18..8eab5c5243273e1e15b47b9287bf8c2730b0be4f 100644 --- a/app/services/pages_domains/create_acme_order_service.rb +++ b/app/services/pages_domains/create_acme_order_service.rb @@ -3,6 +3,9 @@ module PagesDomains class CreateAcmeOrderService attr_reader :pages_domain + # TODO: remove this hack after https://gitlab.com/gitlab-org/gitlab/issues/30146 is implemented + # This makes GitLab automatically retry the certificate obtaining process every 2 hours if process wasn't finished + SHORT_EXPIRATION_DELAY = 2.hours def initialize(pages_domain) @pages_domain = pages_domain @@ -17,7 +20,7 @@ module PagesDomains private_key = OpenSSL::PKey::RSA.new(4096) saved_order = pages_domain.acme_orders.create!( url: order.url, - expires_at: order.expires, + expires_at: [order.expires, SHORT_EXPIRATION_DELAY.from_now].min, private_key: private_key.to_pem, challenge_token: challenge.token, diff --git a/app/services/projects/operations/update_service.rb b/app/services/projects/operations/update_service.rb index 0ca89664304cce76dc82a07ead8c402a39c0d80c..706a6f01a7515984cf40c2ebcd6058a25ea170d7 100644 --- a/app/services/projects/operations/update_service.rb +++ b/app/services/projects/operations/update_service.rb @@ -30,7 +30,7 @@ module Projects settings = params[:error_tracking_setting_attributes] return {} if settings.blank? - api_url = ErrorTracking::ProjectErrorTrackingSetting.build_api_url_from( + api_url = ::ErrorTracking::ProjectErrorTrackingSetting.build_api_url_from( api_host: settings[:api_host], project_slug: settings.dig(:project, :slug), organization_slug: settings.dig(:project, :organization_slug) diff --git a/app/services/projects/update_pages_service.rb b/app/services/projects/update_pages_service.rb index e8a87fc4320aa45bcc65c330dca1b3ab1d524119..8b23f610ad1671c5173430ff5293411117be7bad 100644 --- a/app/services/projects/update_pages_service.rb +++ b/app/services/projects/update_pages_service.rb @@ -6,7 +6,6 @@ module Projects FailedToExtractError = Class.new(StandardError) BLOCK_SIZE = 32.kilobytes - MAX_SIZE = 1.terabyte PUBLIC_DIR = 'public' # this has to be invalid group name, @@ -130,12 +129,16 @@ module Projects 1 + max_size / BLOCK_SIZE end + def max_size_from_settings + Gitlab::CurrentSettings.max_pages_size.megabytes + end + def max_size - max_pages_size = Gitlab::CurrentSettings.max_pages_size.megabytes + max_pages_size = max_size_from_settings - return MAX_SIZE if max_pages_size.zero? + return ::Gitlab::Pages::MAX_SIZE if max_pages_size.zero? - [max_pages_size, MAX_SIZE].min + max_pages_size end def tmp_path @@ -200,3 +203,5 @@ module Projects end end end + +Projects::UpdatePagesService.prepend_if_ee('EE::Projects::UpdatePagesService') diff --git a/app/services/prometheus/adapter_service.rb b/app/services/prometheus/adapter_service.rb deleted file mode 100644 index 399f4c35d665f2c1bf2b720ed833158c5beaa2d0..0000000000000000000000000000000000000000 --- a/app/services/prometheus/adapter_service.rb +++ /dev/null @@ -1,35 +0,0 @@ -# frozen_string_literal: true - -module Prometheus - class AdapterService - def initialize(project, deployment_platform = nil) - @project = project - - @deployment_platform = if deployment_platform - deployment_platform - else - project.deployment_platform - end - end - - attr_reader :deployment_platform, :project - - def prometheus_adapter - @prometheus_adapter ||= if service_prometheus_adapter.can_query? - service_prometheus_adapter - else - cluster_prometheus_adapter - end - end - - def service_prometheus_adapter - project.find_or_initialize_service('prometheus') - end - - def cluster_prometheus_adapter - application = deployment_platform&.cluster&.application_prometheus - - application if application&.available? - end - end -end diff --git a/app/services/prometheus/proxy_service.rb b/app/services/prometheus/proxy_service.rb index a62eb76b8ce8e0c6ab8e20e83d02bd4caaf87726..3585c90fc8f12eb49dc2331ae9a8c3a653fd969b 100644 --- a/app/services/prometheus/proxy_service.rb +++ b/app/services/prometheus/proxy_service.rb @@ -5,9 +5,17 @@ module Prometheus include ReactiveCaching include Gitlab::Utils::StrongMemoize - self.reactive_cache_key = ->(service) { service.cache_key } + self.reactive_cache_key = ->(service) { [] } self.reactive_cache_lease_timeout = 30.seconds - self.reactive_cache_refresh_interval = 30.seconds + + # reactive_cache_refresh_interval should be set to a value higher than + # reactive_cache_lifetime. If the refresh_interval is less than lifetime + # then the ReactiveCachingWorker will re-query prometheus for this + # PromQL query even though it's (probably) already been picked up by + # the frontend + # refresh_interval should be set less than lifetime only if this data + # is expected to change *and* be fetched again by the frontend + self.reactive_cache_refresh_interval = 90.seconds self.reactive_cache_lifetime = 1.minute self.reactive_cache_worker_finder = ->(_id, *args) { from_cache(*args) } diff --git a/app/services/prometheus/proxy_variable_substitution_service.rb b/app/services/prometheus/proxy_variable_substitution_service.rb index ca56292e9d69be30df804364473df57f8ffd05d1..b34afaf80b8ec9def358461a35a827e41029c823 100644 --- a/app/services/prometheus/proxy_variable_substitution_service.rb +++ b/app/services/prometheus/proxy_variable_substitution_service.rb @@ -4,7 +4,10 @@ module Prometheus class ProxyVariableSubstitutionService < BaseService include Stepable - steps :add_params_to_result, :substitute_ruby_variables + steps :validate_variables, + :add_params_to_result, + :substitute_ruby_variables, + :substitute_liquid_variables def initialize(environment, params = {}) @environment, @params = environment, params.deep_dup @@ -16,24 +19,45 @@ module Prometheus private + def validate_variables(_result) + return success unless variables + + unless variables.is_a?(Array) && variables.size.even? + return error(_('Optional parameter "variables" must be an array of keys and values. Ex: [key1, value1, key2, value2]')) + end + + success + end + def add_params_to_result(result) result[:params] = params success(result) end + def substitute_liquid_variables(result) + return success(result) unless query(result) + + result[:params][:query] = + TemplateEngines::LiquidService.new(query(result)).render(full_context) + + success(result) + rescue TemplateEngines::LiquidService::RenderError => e + error(e.message) + end + def substitute_ruby_variables(result) - return success(result) unless query + return success(result) unless query(result) # The % operator doesn't replace variables if the hash contains string # keys. - result[:params][:query] = query % predefined_context.symbolize_keys + result[:params][:query] = query(result) % predefined_context.symbolize_keys success(result) rescue TypeError, ArgumentError => exception log_error(exception.message) - Gitlab::ErrorTracking.track_exception(exception, extra: { - template_string: query, + Gitlab::ErrorTracking.track_exception(exception, { + template_string: query(result), variables: predefined_context }) @@ -44,8 +68,25 @@ module Prometheus @predefined_context ||= Gitlab::Prometheus::QueryVariables.call(@environment) end - def query - params[:query] + def full_context + @full_context ||= predefined_context.reverse_merge(variables_hash) + end + + def variables + params[:variables] + end + + def variables_hash + # .each_slice(2) converts ['key1', 'value1', 'key2', 'value2'] into + # [['key1', 'value1'], ['key2', 'value2']] which is then converted into + # a hash by to_h: {'key1' => 'value1', 'key2' => 'value2'} + # to_h will raise an ArgumentError if the number of elements in the original + # array is not even. + variables&.each_slice(2).to_h + end + + def query(result) + result[:params][:query] end end end diff --git a/app/services/quick_actions/interpret_service.rb b/app/services/quick_actions/interpret_service.rb index a14e0515a1f7010d4959eea66ebc8f0c5800be72..a781eacc40e3d40ee91ca970e9957be9c436f295 100644 --- a/app/services/quick_actions/interpret_service.rb +++ b/app/services/quick_actions/interpret_service.rb @@ -84,7 +84,9 @@ module QuickActions # rubocop: enable CodeReuse/ActiveRecord def find_milestones(project, params = {}) - MilestonesFinder.new(params.merge(project_ids: [project.id], group_ids: [project.group&.id])).execute + group_ids = project.group.self_and_ancestors.select(:id) if project.group + + MilestonesFinder.new(params.merge(project_ids: [project.id], group_ids: group_ids)).execute end def parent diff --git a/app/services/releases/update_service.rb b/app/services/releases/update_service.rb index 6ba8dac21f07d296cadd9ea7a0f8c12357a6e486..a452f7aa17a90cb2abf2d34ec045b878a50b43f2 100644 --- a/app/services/releases/update_service.rb +++ b/app/services/releases/update_service.rb @@ -11,10 +11,13 @@ module Releases return error('params is empty', 400) if empty_params? return error("Milestone(s) not found: #{inexistent_milestones.join(', ')}", 400) if inexistent_milestones.any? - params[:milestones] = milestones if param_for_milestone_titles_provided? + if param_for_milestone_titles_provided? + previous_milestones = release.milestones.map(&:title) + params[:milestones] = milestones + end if release.update(params) - success(tag: existing_tag, release: release) + success(tag: existing_tag, release: release, milestones_updated: milestones_updated?(previous_milestones)) else error(release.errors.messages || '400 Bad request', 400) end @@ -29,5 +32,11 @@ module Releases def empty_params? params.except(:tag).empty? end + + def milestones_updated?(previous_milestones) + return false unless param_for_milestone_titles_provided? + + previous_milestones.to_set != release.milestones.map(&:title) + end end end diff --git a/app/services/resource_events/base_synthetic_notes_builder_service.rb b/app/services/resource_events/base_synthetic_notes_builder_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..1b85ca811a1ea1c43506992842161c3540ed5b20 --- /dev/null +++ b/app/services/resource_events/base_synthetic_notes_builder_service.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +# We store events about issuable label changes and weight changes in a separate +# table (not as other system notes), but we still want to display notes about +# label changes and weight changes as classic system notes in UI. This service +# generates "synthetic" notes for label event changes. + +module ResourceEvents + class BaseSyntheticNotesBuilderService + include Gitlab::Utils::StrongMemoize + + attr_reader :resource, :current_user, :params + + def initialize(resource, current_user, params = {}) + @resource = resource + @current_user = current_user + @params = params + end + + def execute + synthetic_notes + end + + private + + def since_fetch_at(events) + return events unless params[:last_fetched_at].present? + + last_fetched_at = Time.at(params.fetch(:last_fetched_at).to_i) + events.created_after(last_fetched_at - NotesFinder::FETCH_OVERLAP) + end + + def resource_parent + strong_memoize(:resource_parent) do + resource.project || resource.group + end + end + end +end diff --git a/app/services/resource_events/merge_into_notes_service.rb b/app/services/resource_events/merge_into_notes_service.rb index 7504773a0020cd46fa17fd39e7497718dbde2a62..47948fcff6e4a2c232dea35ff6f7c8e30a11d149 100644 --- a/app/services/resource_events/merge_into_notes_service.rb +++ b/app/services/resource_events/merge_into_notes_service.rb @@ -1,10 +1,9 @@ # frozen_string_literal: true -# We store events about issuable label changes in a separate table (not as -# other system notes), but we still want to display notes about label changes -# as classic system notes in UI. This service generates "synthetic" notes for -# label event changes and merges them with classic notes and sorts them by -# creation time. +# We store events about issuable label changes and weight changes in separate tables (not as +# other system notes), but we still want to display notes about label and weight changes +# as classic system notes in UI. This service merges synthetic label and weight notes +# with classic notes and sorts them by creation time. module ResourceEvents class MergeIntoNotesService @@ -19,39 +18,15 @@ module ResourceEvents end def execute(notes = []) - (notes + label_notes).sort_by { |n| n.created_at } + (notes + synthetic_notes).sort_by { |n| n.created_at } end private - def label_notes - label_events_by_discussion_id.map do |discussion_id, events| - LabelNote.from_events(events, resource: resource, resource_parent: resource_parent) - end - end - - # rubocop: disable CodeReuse/ActiveRecord - def label_events_by_discussion_id - return [] unless resource.respond_to?(:resource_label_events) - - events = resource.resource_label_events.includes(:label, user: :status) - events = since_fetch_at(events) - - events.group_by { |event| event.discussion_id } - end - # rubocop: enable CodeReuse/ActiveRecord - - def since_fetch_at(events) - return events unless params[:last_fetched_at].present? - - last_fetched_at = Time.at(params.fetch(:last_fetched_at).to_i) - events.created_after(last_fetched_at - NotesFinder::FETCH_OVERLAP) - end - - def resource_parent - strong_memoize(:resource_parent) do - resource.project || resource.group - end + def synthetic_notes + SyntheticLabelNotesBuilderService.new(resource, current_user, params).execute end end end + +ResourceEvents::MergeIntoNotesService.prepend_if_ee('EE::ResourceEvents::MergeIntoNotesService') diff --git a/app/services/resource_events/synthetic_label_notes_builder_service.rb b/app/services/resource_events/synthetic_label_notes_builder_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..fd128101b495fd524a43dfa80a4d66be5ee29bb8 --- /dev/null +++ b/app/services/resource_events/synthetic_label_notes_builder_service.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +# We store events about issuable label changes in a separate table (not as +# other system notes), but we still want to display notes about label changes +# as classic system notes in UI. This service generates "synthetic" notes for +# label event changes. + +module ResourceEvents + class SyntheticLabelNotesBuilderService < BaseSyntheticNotesBuilderService + private + + def synthetic_notes + label_events_by_discussion_id.map do |discussion_id, events| + LabelNote.from_events(events, resource: resource, resource_parent: resource_parent) + end + end + + def label_events_by_discussion_id + return [] unless resource.respond_to?(:resource_label_events) + + events = resource.resource_label_events.includes(:label, user: :status) # rubocop: disable CodeReuse/ActiveRecord + events = since_fetch_at(events) + + events.group_by { |event| event.discussion_id } + end + end +end diff --git a/app/services/search_service.rb b/app/services/search_service.rb index 91c0f9ba1040791e4e2d7cdf9721e48cd7358f7d..fe5e823b56c63245c71536a6124f6b14b4486a6b 100644 --- a/app/services/search_service.rb +++ b/app/services/search_service.rb @@ -3,6 +3,9 @@ class SearchService include Gitlab::Allowable + SEARCH_TERM_LIMIT = 64 + SEARCH_CHAR_LIMIT = 4096 + def initialize(current_user, params = {}) @current_user = current_user @params = params.dup @@ -42,6 +45,14 @@ class SearchService @show_snippets = params[:snippets] == 'true' end + def valid_query_length? + params[:search].length <= SEARCH_CHAR_LIMIT + end + + def valid_terms_count? + params[:search].split.count { |word| word.length >= 3 } <= SEARCH_TERM_LIMIT + end + delegate :scope, to: :search_service def search_results diff --git a/app/services/snippets/base_service.rb b/app/services/snippets/base_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..2b450db0b83a0e82e0a69fbaab39033a5c9c0a0a --- /dev/null +++ b/app/services/snippets/base_service.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Snippets + class BaseService < ::BaseService + private + + def snippet_error_response(snippet, http_status) + ServiceResponse.error( + message: snippet.errors.full_messages.to_sentence, + http_status: http_status, + payload: { snippet: snippet } + ) + end + end +end diff --git a/app/services/snippets/create_service.rb b/app/services/snippets/create_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..250e99c466a4d0bfc4322b4905c5436c45abacc9 --- /dev/null +++ b/app/services/snippets/create_service.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Snippets + class CreateService < Snippets::BaseService + include SpamCheckMethods + + def execute + filter_spam_check_params + + snippet = if project + project.snippets.build(params) + else + PersonalSnippet.new(params) + end + + unless Gitlab::VisibilityLevel.allowed_for?(current_user, snippet.visibility_level) + deny_visibility_level(snippet) + + return snippet_error_response(snippet, 403) + end + + snippet.author = current_user + + spam_check(snippet, current_user) + + snippet_saved = snippet.with_transaction_returning_status do + snippet.save && snippet.store_mentions! + end + + if snippet_saved + UserAgentDetailService.new(snippet, @request).create + Gitlab::UsageDataCounters::SnippetCounter.count(:create) + + ServiceResponse.success(payload: { snippet: snippet } ) + else + snippet_error_response(snippet, 400) + end + end + end +end diff --git a/app/services/snippets/destroy_service.rb b/app/services/snippets/destroy_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..f253817d94fd53b1ef85bf1ccfb730a5ce064f9d --- /dev/null +++ b/app/services/snippets/destroy_service.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module Snippets + class DestroyService + include Gitlab::Allowable + + attr_reader :current_user, :project + + def initialize(user, snippet) + @current_user = user + @snippet = snippet + @project = snippet&.project + end + + def execute + if snippet.nil? + return service_response_error('No snippet found.', 404) + end + + unless user_can_delete_snippet? + return service_response_error( + "You don't have access to delete this snippet.", + 403 + ) + end + + if snippet.destroy + ServiceResponse.success(message: 'Snippet was deleted.') + else + service_response_error('Failed to remove snippet.', 400) + end + end + + private + + attr_reader :snippet + + def user_can_delete_snippet? + return can?(current_user, :admin_project_snippet, snippet) if project + + can?(current_user, :admin_personal_snippet, snippet) + end + + def service_response_error(message, http_status) + ServiceResponse.error(message: message, http_status: http_status) + end + end +end diff --git a/app/services/snippets/update_service.rb b/app/services/snippets/update_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..8d2c8cac1483f967f7a748c9d52f37b23c63af41 --- /dev/null +++ b/app/services/snippets/update_service.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module Snippets + class UpdateService < Snippets::BaseService + include SpamCheckMethods + + def execute(snippet) + # check that user is allowed to set specified visibility_level + new_visibility = visibility_level + + if new_visibility && new_visibility.to_i != snippet.visibility_level + unless Gitlab::VisibilityLevel.allowed_for?(current_user, new_visibility) + deny_visibility_level(snippet, new_visibility) + + return snippet_error_response(snippet, 403) + end + end + + filter_spam_check_params + snippet.assign_attributes(params) + spam_check(snippet, current_user) + + snippet_saved = snippet.with_transaction_returning_status do + snippet.save && snippet.store_mentions! + end + + if snippet_saved + Gitlab::UsageDataCounters::SnippetCounter.count(:update) + + ServiceResponse.success(payload: { snippet: snippet } ) + else + snippet_error_response(snippet, 400) + end + end + end +end diff --git a/app/services/spam/mark_as_spam_service.rb b/app/services/spam/mark_as_spam_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..0ebcf17927a1bd86771c9808a45588123414670a --- /dev/null +++ b/app/services/spam/mark_as_spam_service.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Spam + class MarkAsSpamService + include ::AkismetMethods + + attr_accessor :spammable, :options + + def initialize(spammable:) + @spammable = spammable + @options = {} + + @options[:ip_address] = @spammable.ip_address + @options[:user_agent] = @spammable.user_agent + end + + def execute + return unless spammable.submittable_as_spam? + return unless akismet.submit_spam + + spammable.user_agent_detail.update_attribute(:submitted, true) + end + end +end diff --git a/app/services/spam_service.rb b/app/services/spam_service.rb index babe69cfdc8cbe6ec4fdf020c893d03a98d8a49e..ba9b812a01c5974a884fb4ea035b29f225136de6 100644 --- a/app/services/spam_service.rb +++ b/app/services/spam_service.rb @@ -1,10 +1,12 @@ # frozen_string_literal: true class SpamService + include AkismetMethods + attr_accessor :spammable, :request, :options attr_reader :spam_log - def initialize(spammable, request = nil) + def initialize(spammable:, request:) @spammable = spammable @request = request @options = {} @@ -19,16 +21,6 @@ class SpamService end end - def mark_as_spam! - return false unless spammable.submittable_as_spam? - - if akismet.submit_spam - spammable.user_agent_detail.update_attribute(:submitted, true) - else - false - end - end - def when_recaptcha_verified(recaptcha_verified, api = false) # In case it's a request which is already verified through recaptcha, yield # block. @@ -54,27 +46,6 @@ class SpamService true end - def akismet - @akismet ||= AkismetService.new( - spammable_owner, - spammable.spammable_text, - options - ) - end - - def spammable_owner - @user ||= User.find(spammable_owner_id) - end - - def spammable_owner_id - @owner_id ||= - if spammable.respond_to?(:author_id) - spammable.author_id - elsif spammable.respond_to?(:creator_id) - spammable.creator_id - end - end - def check_for_spam? spammable.check_for_spam? end diff --git a/app/services/suggestions/apply_service.rb b/app/services/suggestions/apply_service.rb index 8ba50e22b096f5f22050bd6217fe491a87307274..a6485e42bdb9f853cfcef744a2a6467872576cfa 100644 --- a/app/services/suggestions/apply_service.rb +++ b/app/services/suggestions/apply_service.rb @@ -2,6 +2,24 @@ module Suggestions class ApplyService < ::BaseService + DEFAULT_SUGGESTION_COMMIT_MESSAGE = 'Apply suggestion to %{file_path}' + + PLACEHOLDERS = { + 'project_path' => ->(suggestion, user) { suggestion.project.path }, + 'project_name' => ->(suggestion, user) { suggestion.project.name }, + 'file_path' => ->(suggestion, user) { suggestion.file_path }, + 'branch_name' => ->(suggestion, user) { suggestion.branch }, + 'username' => ->(suggestion, user) { user.username }, + 'user_full_name' => ->(suggestion, user) { user.name } + }.freeze + + # This regex is built dynamically using the keys from the PLACEHOLDER struct. + # So, we can easily add new placeholder just by modifying the PLACEHOLDER hash. + # This regex will build the new PLACEHOLDER_REGEX with the new information + PLACEHOLDERS_REGEX = Regexp.union(PLACEHOLDERS.keys.map { |key| Regexp.new(Regexp.escape(key)) }).freeze + + attr_reader :current_user + def initialize(current_user) @current_user = current_user end @@ -22,7 +40,7 @@ module Suggestions end params = file_update_params(suggestion, diff_file) - result = ::Files::UpdateService.new(suggestion.project, @current_user, params).execute + result = ::Files::UpdateService.new(suggestion.project, current_user, params).execute if result[:status] == :success suggestion.update(commit_id: result[:result], applied: true) @@ -46,13 +64,14 @@ module Suggestions def file_update_params(suggestion, diff_file) blob = diff_file.new_blob + project = suggestion.project file_path = suggestion.file_path branch_name = suggestion.branch file_content = new_file_content(suggestion, blob) - commit_message = "Apply suggestion to #{file_path}" + commit_message = processed_suggestion_commit_message(suggestion) file_last_commit = - Gitlab::Git::Commit.last_for_path(suggestion.project.repository, + Gitlab::Git::Commit.last_for_path(project.repository, blob.commit_id, blob.path) @@ -75,5 +94,17 @@ module Suggestions content.join end + + def suggestion_commit_message(project) + project.suggestion_commit_message || DEFAULT_SUGGESTION_COMMIT_MESSAGE + end + + def processed_suggestion_commit_message(suggestion) + message = suggestion_commit_message(suggestion.project) + + Gitlab::StringPlaceholderReplacer.replace_string_placeholders(message, PLACEHOLDERS_REGEX) do |key| + PLACEHOLDERS[key].call(suggestion, current_user) + end + end end end diff --git a/app/services/system_hooks_service.rb b/app/services/system_hooks_service.rb index 06d2037fb63619969f5b65f122a9a3a12947ece1..0d369c23b57ac12463a95f1ec0823fa80c16a931 100644 --- a/app/services/system_hooks_service.rb +++ b/app/services/system_hooks_service.rb @@ -14,7 +14,7 @@ class SystemHooksService hook.async_execute(data, 'system_hooks') end - Gitlab::Plugin.execute_all_async(data) + Gitlab::FileHook.execute_all_async(data) end private diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb index 25e3282d3fbc5d180e0d4c8d76da28ff1965b032..38e0a7d34ad6bc07e615a31edf22d7acb2702e72 100644 --- a/app/services/system_note_service.rb +++ b/app/services/system_note_service.rb @@ -60,9 +60,7 @@ module SystemNoteService # # Returns the created Note object def change_due_date(noteable, project, author, due_date) - body = due_date ? "changed due date to #{due_date.to_s(:long)}" : 'removed due date' - - create_note(NoteSummary.new(noteable, project, author, body, action: 'due_date')) + ::SystemNotes::TimeTrackingService.new(noteable: noteable, project: project, author: author).change_due_date(due_date) end # Called when the estimated time of a Noteable is changed @@ -80,14 +78,7 @@ module SystemNoteService # # Returns the created Note object def change_time_estimate(noteable, project, author) - parsed_time = Gitlab::TimeTrackingFormatter.output(noteable.time_estimate) - body = if noteable.time_estimate == 0 - "removed time estimate" - else - "changed time estimate to #{parsed_time}" - end - - create_note(NoteSummary.new(noteable, project, author, body, action: 'time_tracking')) + ::SystemNotes::TimeTrackingService.new(noteable: noteable, project: project, author: author).change_time_estimate end # Called when the spent time of a Noteable is changed @@ -105,21 +96,7 @@ module SystemNoteService # # Returns the created Note object def change_time_spent(noteable, project, author) - time_spent = noteable.time_spent - - if time_spent == :reset - body = "removed time spent" - else - spent_at = noteable.spent_at - parsed_time = Gitlab::TimeTrackingFormatter.output(time_spent.abs) - action = time_spent > 0 ? 'added' : 'subtracted' - - text_parts = ["#{action} #{parsed_time} of time spent"] - text_parts << "at #{spent_at}" if spent_at - body = text_parts.join(' ') - end - - create_note(NoteSummary.new(noteable, project, author, body, action: 'time_tracking')) + ::SystemNotes::TimeTrackingService.new(noteable: noteable, project: project, author: author).change_time_spent end def change_status(noteable, project, author, status, source = nil) diff --git a/app/services/system_notes/time_tracking_service.rb b/app/services/system_notes/time_tracking_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..8de42bd322567ae549f5f8ad293a16f272e376a7 --- /dev/null +++ b/app/services/system_notes/time_tracking_service.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +module SystemNotes + class TimeTrackingService < ::SystemNotes::BaseService + # Called when the due_date of a Noteable is changed + # + # due_date - Due date being assigned, or nil + # + # Example Note text: + # + # "removed due date" + # + # "changed due date to September 20, 2018" + # + # Returns the created Note object + def change_due_date(due_date) + body = due_date ? "changed due date to #{due_date.to_s(:long)}" : 'removed due date' + + create_note(NoteSummary.new(noteable, project, author, body, action: 'due_date')) + end + + # Called when the estimated time of a Noteable is changed + # + # time_estimate - Estimated time + # + # Example Note text: + # + # "removed time estimate" + # + # "changed time estimate to 3d 5h" + # + # Returns the created Note object + def change_time_estimate + parsed_time = Gitlab::TimeTrackingFormatter.output(noteable.time_estimate) + body = if noteable.time_estimate == 0 + "removed time estimate" + else + "changed time estimate to #{parsed_time}" + end + + create_note(NoteSummary.new(noteable, project, author, body, action: 'time_tracking')) + end + + # Called when the spent time of a Noteable is changed + # + # time_spent - Spent time + # + # Example Note text: + # + # "removed time spent" + # + # "added 2h 30m of time spent" + # + # Returns the created Note object + def change_time_spent + time_spent = noteable.time_spent + + if time_spent == :reset + body = "removed time spent" + else + spent_at = noteable.spent_at + parsed_time = Gitlab::TimeTrackingFormatter.output(time_spent.abs) + action = time_spent > 0 ? 'added' : 'subtracted' + + text_parts = ["#{action} #{parsed_time} of time spent"] + text_parts << "at #{spent_at}" if spent_at + body = text_parts.join(' ') + end + + create_note(NoteSummary.new(noteable, project, author, body, action: 'time_tracking')) + end + end +end diff --git a/app/services/template_engines/liquid_service.rb b/app/services/template_engines/liquid_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..809ebd0316b82b9e3d7c2b0e61b80a105dccbfb5 --- /dev/null +++ b/app/services/template_engines/liquid_service.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module TemplateEngines + class LiquidService < BaseService + RenderError = Class.new(StandardError) + + DEFAULT_RENDER_SCORE_LIMIT = 1_000 + + def initialize(string) + @template = Liquid::Template.parse(string) + end + + def render(context, render_score_limit: DEFAULT_RENDER_SCORE_LIMIT) + set_limits(render_score_limit) + + @template.render!(context.stringify_keys) + rescue Liquid::MemoryError => e + handle_exception(e, string: @string, context: context) + + raise RenderError, _('Memory limit exceeded while rendering template') + rescue Liquid::Error => e + handle_exception(e, string: @string, context: context) + + raise RenderError, _('Error rendering query') + end + + private + + def set_limits(render_score_limit) + @template.resource_limits.render_score_limit = render_score_limit + + # We can also set assign_score_limit and render_length_limit if required. + + # render_score_limit limits the number of nodes (string, variable, block, tags) + # that are allowed in the template. + # render_length_limit seems to limit the sum of the bytesize of all node blocks. + # assign_score_limit seems to limit the sum of the bytesize of all capture blocks. + end + + def handle_exception(exception, extra = {}) + log_error(exception.message) + Gitlab::ErrorTracking.track_exception(exception, { + template_string: extra[:string], + variables: extra[:context] + }) + end + end +end diff --git a/app/services/update_snippet_service.rb b/app/services/update_snippet_service.rb deleted file mode 100644 index ac7f8e9b1f5feb36a4d6403373f98220d832e225..0000000000000000000000000000000000000000 --- a/app/services/update_snippet_service.rb +++ /dev/null @@ -1,36 +0,0 @@ -# frozen_string_literal: true - -class UpdateSnippetService < BaseService - include SpamCheckService - - attr_accessor :snippet - - def initialize(project, user, snippet, params) - super(project, user, params) - @snippet = snippet - end - - def execute - # check that user is allowed to set specified visibility_level - new_visibility = visibility_level - - if new_visibility && new_visibility.to_i != snippet.visibility_level - unless Gitlab::VisibilityLevel.allowed_for?(current_user, new_visibility) - deny_visibility_level(snippet, new_visibility) - return snippet - end - end - - filter_spam_check_params - snippet.assign_attributes(params) - spam_check(snippet, current_user) - - snippet_saved = snippet.with_transaction_returning_status do - snippet.save && snippet.store_mentions! - end - - if snippet_saved - Gitlab::UsageDataCounters::SnippetCounter.count(:update) - end - end -end diff --git a/app/services/users/activity_service.rb b/app/services/users/activity_service.rb index 33444c2a7dc1b30ccc84bca6ad1b1f4773a6e5bd..85855f45e338de87d955e74be6590f131d795248 100644 --- a/app/services/users/activity_service.rb +++ b/app/services/users/activity_service.rb @@ -4,7 +4,7 @@ module Users class ActivityService LEASE_TIMEOUT = 1.minute.to_i - def initialize(author, activity) + def initialize(author) @user = if author.respond_to?(:username) author elsif author.respond_to?(:user) @@ -12,7 +12,6 @@ module Users end @user = nil unless @user.is_a?(User) - @activity = activity end def execute diff --git a/app/services/users/build_service.rb b/app/services/users/build_service.rb index ea4d11e728e09ef95dac67cbca45290b0ac3c88d..d18f20bc1db254ab40d525c62eafe6a7ae104cee 100644 --- a/app/services/users/build_service.rb +++ b/app/services/users/build_service.rb @@ -86,6 +86,8 @@ module Users :email_confirmation, :password_automatically_set, :name, + :first_name, + :last_name, :password, :username ] @@ -107,6 +109,12 @@ module Users if user_params[:skip_confirmation].nil? user_params[:skip_confirmation] = skip_user_confirmation_email_from_setting end + + fallback_name = "#{user_params[:first_name]} #{user_params[:last_name]}" + + if user_params[:name].blank? && fallback_name.present? + user_params = user_params.merge(name: fallback_name) + end end if user_default_internal_regex_enabled? && !user_params.key?(:external) diff --git a/app/services/users/destroy_service.rb b/app/services/users/destroy_service.rb index e341c7f05372ccadf0a4e908c74f019d0dd7b3ec..643ebdc68398cc10e3e1e60d1048f3cb61795f24 100644 --- a/app/services/users/destroy_service.rb +++ b/app/services/users/destroy_service.rb @@ -56,6 +56,13 @@ module Users MigrateToGhostUserService.new(user).execute unless options[:hard_delete] + if Feature.enabled?(:destroy_user_associations_in_batches) + # Rails attempts to load all related records into memory before + # destroying: https://github.com/rails/rails/issues/22510 + # This ensures we delete records in batches. + user.destroy_dependent_associations_in_batches + end + # Destroy the namespace after destroying the user since certain methods may depend on the namespace existing user_data = user.destroy namespace.destroy diff --git a/app/services/users/update_service.rb b/app/services/users/update_service.rb index 422c8ed6575db69b6e4a9e2b4e5786e30c72783b..e7667b0ca18634a429552d974d762c7ee1ce16dc 100644 --- a/app/services/users/update_service.rb +++ b/app/services/users/update_service.rb @@ -17,6 +17,8 @@ module Users yield(@user) if block_given? user_exists = @user.persisted? + + discard_read_only_attributes assign_attributes assign_identity @@ -50,13 +52,19 @@ module Users success end - def assign_attributes + def discard_read_only_attributes + discard_synced_attributes + end + + def discard_synced_attributes if (metadata = @user.user_synced_attributes_metadata) read_only = metadata.read_only_attributes params.reject! { |key, _| read_only.include?(key.to_sym) } end + end + def assign_attributes @user.assign_attributes(params.except(*identity_attributes)) unless params.empty? end diff --git a/app/uploaders/avatar_uploader.rb b/app/uploaders/avatar_uploader.rb index d42c9dbedf4894911800cfb8485d292d575cf9cd..b79a5deb9c0735cab2e37ec13b5848dd09c57cb7 100644 --- a/app/uploaders/avatar_uploader.rb +++ b/app/uploaders/avatar_uploader.rb @@ -5,6 +5,9 @@ class AvatarUploader < GitlabUploader include RecordsUploads::Concern include ObjectStorage::Concern prepend ObjectStorage::Extension::RecordsUploads + include UploadTypeCheck::Concern + + check_upload_type extensions: AvatarUploader::SAFE_IMAGE_EXT def exists? model.avatar.file && model.avatar.file.present? diff --git a/app/uploaders/favicon_uploader.rb b/app/uploaders/favicon_uploader.rb index a0b275b56a94764e707ea8e0012a353588eb5229..f393fdf0d845c27f2151b5ad83165e32ec12009d 100644 --- a/app/uploaders/favicon_uploader.rb +++ b/app/uploaders/favicon_uploader.rb @@ -1,8 +1,12 @@ # frozen_string_literal: true class FaviconUploader < AttachmentUploader + include UploadTypeCheck::Concern + EXTENSION_WHITELIST = %w[png ico].freeze + check_upload_type extensions: EXTENSION_WHITELIST + def extension_whitelist EXTENSION_WHITELIST end diff --git a/app/uploaders/upload_type_check.rb b/app/uploaders/upload_type_check.rb new file mode 100644 index 0000000000000000000000000000000000000000..2837b00166023580f744152c038319d7452289e8 --- /dev/null +++ b/app/uploaders/upload_type_check.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +# Ensure that uploaded files are what they say they are for security and +# handling purposes. The checks are not 100% reliable so we err on the side of +# caution and allow by default, and deny when we're confident of a fail state. +# +# Include this concern, then call `check_upload_type` to check all +# uploads. Attach a `mime_type` or `extensions` parameter to only check +# specific upload types. Both parameters will be normalized to a MIME type and +# checked against the inferred MIME type of the upload content and filename +# extension. +# +# class YourUploader +# include UploadTypeCheck::Concern +# check_upload_type mime_types: ['image/png', /image\/jpe?g/] +# +# # or... +# +# check_upload_type extensions: ['png', 'jpg', 'jpeg'] +# end +# +# The mime_types parameter can accept `NilClass`, `String`, `Regexp`, +# `Array[String, Regexp]`. This matches the CarrierWave `extension_whitelist` +# and `content_type_whitelist` family of behavior. +# +# The extensions parameter can accept `NilClass`, `String`, `Array[String]`. +module UploadTypeCheck + module Concern + extend ActiveSupport::Concern + + class_methods do + def check_upload_type(mime_types: nil, extensions: nil) + define_method :check_upload_type_callback do |file| + magic_file = MagicFile.new(file.to_file) + + # Map file extensions back to mime types. + if extensions + mime_types = Array(mime_types) + + Array(extensions).map { |e| MimeMagic::EXTENSIONS[e] } + end + + if mime_types.nil? || magic_file.matches_mime_types?(mime_types) + check_content_matches_extension!(magic_file) + end + end + before :cache, :check_upload_type_callback + end + end + + def check_content_matches_extension!(magic_file) + return if magic_file.ambiguous_type? + + if magic_file.magic_type != magic_file.ext_type + raise CarrierWave::IntegrityError, 'Content type does not match file extension' + end + end + end + + # Convenience class to wrap MagicMime objects. + class MagicFile + attr_reader :file + + def initialize(file) + @file = file + end + + def magic_type + @magic_type ||= MimeMagic.by_magic(file) + end + + def ext_type + @ext_type ||= MimeMagic.by_path(file.path) + end + + def magic_type_type + magic_type&.type + end + + def ext_type_type + ext_type&.type + end + + def matches_mime_types?(mime_types) + Array(mime_types).any? do |mt| + magic_type_type =~ /\A#{mt}\z/ || ext_type_type =~ /\A#{mt}\z/ + end + end + + # - Both types unknown or text/plain. + # - Ambiguous magic type with text extension. Plain text file. + # - Text magic type with ambiguous extension. TeX file missing extension. + def ambiguous_type? + (ext_type.to_s.blank? && magic_type.to_s.blank?) || + (magic_type.to_s.blank? && ext_type_type == 'text/plain') || + (ext_type.to_s.blank? && magic_type_type == 'text/plain') + end + end +end diff --git a/app/validators/key_restriction_validator.rb b/app/validators/key_restriction_validator.rb index 891d13b15964cd12ffd01a8382eb0fbda1cb55b4..9809047ae83cfdaf0652a9d5585e127a6c50bf73 100644 --- a/app/validators/key_restriction_validator.rb +++ b/app/validators/key_restriction_validator.rb @@ -21,7 +21,8 @@ class KeyRestrictionValidator < ActiveModel::EachValidator def supported_sizes_message sizes = self.class.supported_sizes(options[:type]) - sizes.to_sentence(last_word_connector: ', or ', two_words_connector: ' or ') + + Gitlab::Utils.to_exclusive_sentence(sizes) end def valid_restriction?(value) diff --git a/app/views/admin/application_settings/_account_and_limit.html.haml b/app/views/admin/application_settings/_account_and_limit.html.haml index 6b95c0f40c5c7a3e6d8f3737b3f546b25b24aad5..80a53dba2aa7547304f64486af4ad4a83100dede 100644 --- a/app/views/admin/application_settings/_account_and_limit.html.haml +++ b/app/views/admin/application_settings/_account_and_limit.html.haml @@ -52,6 +52,7 @@ = f.label :user_show_add_ssh_key_message, class: 'form-check-label' do = _("Inform users without uploaded SSH keys that they can't push over SSH until one is added") + = render_if_exists 'admin/application_settings/updating_name_disabled_for_users', form: f = render_if_exists 'admin/application_settings/availability_on_namespace_setting', form: f = f.submit _('Save changes'), class: 'btn btn-success qa-save-changes-button' diff --git a/app/views/admin/application_settings/_gitaly.html.haml b/app/views/admin/application_settings/_gitaly.html.haml index 1da02de046192ce53832a7973a86e84a762192e1..fac2de8811fe818bae30c79d82d50e045364f488 100644 --- a/app/views/admin/application_settings/_gitaly.html.haml +++ b/app/views/admin/application_settings/_gitaly.html.haml @@ -8,6 +8,9 @@ .form-text.text-muted Timeout for Gitaly calls from the GitLab application (in seconds). This timeout is not enforced for git fetch/push operations or Sidekiq jobs. + This timeout should be less than the worker timeout. If a Gitaly call timeout would exceed the + worker timeout, the remaining time from the worker timeout would be used to avoid having to terminate + the worker. .form-group = f.label :gitaly_timeout_fast, 'Fast Timeout Period', class: 'label-bold' = f.number_field :gitaly_timeout_fast, class: 'form-control' diff --git a/app/views/admin/application_settings/_pages.html.haml b/app/views/admin/application_settings/_pages.html.haml index b15afb3b806157082c863c42b9f04e2fb0c0583e..8214cf8ce9f837206d0ba1a4d1c715b61b540487 100644 --- a/app/views/admin/application_settings/_pages.html.haml +++ b/app/views/admin/application_settings/_pages.html.haml @@ -15,6 +15,15 @@ .form-text.text-muted = _("Domain verification is an essential security measure for public GitLab sites. Users are required to demonstrate they control a domain before it is enabled") = link_to icon('question-circle'), help_page_path('user/project/pages/custom_domains_ssl_tls_certification/index.md', anchor: '4-verify-the-domains-ownership') + - if Gitlab.config.pages.access_control + .form-group + .form-check + = f.check_box :force_pages_access_control, class: 'form-check-input' + = f.label :force_pages_access_control, class: 'form-check-label' do + = _("Disable public access to Pages sites") + .form-text.text-muted + = _("Access to Pages websites are controlled based on the user's membership to a given project. By checking this box, users will be required to be logged in to have access to all Pages websites in your instance.") + = link_to icon('question-circle'), help_page_path('administration/pages/index.md', anchor: 'disabling-public-access-to-all-pages-websites') %h5 = _("Configure Let's Encrypt") %p diff --git a/app/views/admin/application_settings/_signup.html.haml b/app/views/admin/application_settings/_signup.html.haml index b9d9d86ca30a8f7fc1a4720da5e4a6ab2283dd9c..c29e52abaf6d1c44e591f4033bb37df4b7c066f1 100644 --- a/app/views/admin/application_settings/_signup.html.haml +++ b/app/views/admin/application_settings/_signup.html.haml @@ -7,6 +7,8 @@ = f.check_box :signup_enabled, class: 'form-check-input' = f.label :signup_enabled, class: 'form-check-label' do Sign-up enabled + .form-text.text-muted + = _("When enabled, any user visiting %{host} will be able to create an account.") % { host: "#{new_user_session_url(host: Gitlab.config.gitlab.host)}" } .form-group .form-check = f.check_box :send_user_confirmation_email, class: 'form-check-input' diff --git a/app/views/admin/application_settings/metrics_and_profiling.html.haml b/app/views/admin/application_settings/metrics_and_profiling.html.haml index 55a48da834213a58c4d1e4e4fc8e9fd83d7606e8..ff40d7da8925be09adff3dbc228faf39aa36601a 100644 --- a/app/views/admin/application_settings/metrics_and_profiling.html.haml +++ b/app/views/admin/application_settings/metrics_and_profiling.html.haml @@ -47,6 +47,9 @@ .settings-content = render 'performance_bar' +- if Feature.enabled?(:self_monitoring_project) + .js-self-monitoring-settings{ data: self_monitoring_project_data } + %section.settings.as-usage.no-animate#js-usage-settings{ class: ('expanded' if expanded_by_default?) } .settings-header#usage-statistics %h4 diff --git a/app/views/admin/broadcast_messages/_form.html.haml b/app/views/admin/broadcast_messages/_form.html.haml index 44d57beec0f201809001c155429a881be33d02d1..33b56655206d55b78199f3cd7dba4c63b4f6380f 100644 --- a/app/views/admin/broadcast_messages/_form.html.haml +++ b/app/views/admin/broadcast_messages/_form.html.haml @@ -1,23 +1,38 @@ -.broadcast-message-preview{ style: broadcast_message_style(@broadcast_message) } - = sprite_icon('bullhorn', size: 16, css_class:'vertical-align-text-top mr-2') +.broadcast-banner-message.js-broadcast-banner-message-preview.mt-2{ style: broadcast_message_style(@broadcast_message), class: ('hidden' unless @broadcast_message.banner? ) } + = sprite_icon('bullhorn', size: 16, css_class:'vertical-align-text-top') .js-broadcast-message-preview - if @broadcast_message.message.present? = render_broadcast_message(@broadcast_message) - else Your message here +- if Feature.enabled?(:broadcast_notification_type) + .d-flex.justify-content-center + .broadcast-notification-message.preview.js-broadcast-notification-message-preview.mt-2{ class: ('hidden' unless @broadcast_message.notification? ) } + = sprite_icon('bullhorn', size: 16, css_class:'vertical-align-text-top') + .js-broadcast-message-preview + - if @broadcast_message.message.present? + = render_broadcast_message(@broadcast_message) + - else + Your message here = form_for [:admin, @broadcast_message], html: { class: 'broadcast-message-form js-quick-submit js-requires-input'} do |f| = form_errors(@broadcast_message) - .form-group.row + .form-group.row.mt-4 .col-sm-2.col-form-label = f.label :message .col-sm-10 - = f.text_area :message, class: "form-control js-autosize", + = f.text_area :message, class: "form-control js-autosize js-broadcast-message-message", required: true, dir: 'auto', data: { preview_path: preview_admin_broadcast_messages_path } - .form-group.row + - if Feature.enabled?(:broadcast_notification_type) + .form-group.row + .col-sm-2.col-form-label + = f.label :broadcast_type, _('Type') + .col-sm-10 + = f.select :broadcast_type, broadcast_type_options, {}, class: 'form-control js-broadcast-message-type' + .form-group.row.js-broadcast-message-background-color-form-group{ class: ('hidden' unless @broadcast_message.banner? ) } .col-sm-2.col-form-label = f.label :color, _("Background color") .col-sm-10 @@ -25,7 +40,7 @@ .input-group-prepend .input-group-text.label-color-preview{ :style => 'background-color: ' + @broadcast_message.color + '; color: ' + @broadcast_message.font } = ' '.html_safe - = f.text_field :color, class: "form-control" + = f.text_field :color, class: "form-control js-broadcast-message-color" .form-text.text-muted = _('Choose any color.') %br diff --git a/app/views/admin/broadcast_messages/index.html.haml b/app/views/admin/broadcast_messages/index.html.haml index 4731421fd9e2d4d4a81b6da7f897c9d91ee3983e..6f2433e3306564e892c070b8e7cfb4626507e242 100644 --- a/app/views/admin/broadcast_messages/index.html.haml +++ b/app/views/admin/broadcast_messages/index.html.haml @@ -20,6 +20,7 @@ %th Starts %th Ends %th Target Path + %th Type %th %tbody - @broadcast_messages.each do |message| @@ -27,13 +28,15 @@ %td = broadcast_message_status(message) %td - = broadcast_message(message) + = broadcast_message(message, preview: true) %td = message.starts_at %td = message.ends_at %td = message.target_path + %td + = message.broadcast_type.capitalize %td = link_to sprite_icon('pencil-square'), edit_admin_broadcast_message_path(message), title: 'Edit', class: 'btn' = link_to sprite_icon('remove'), admin_broadcast_message_path(message), method: :delete, remote: true, title: 'Remove', class: 'js-remove-tr btn btn-danger' diff --git a/app/views/admin/hooks/index.html.haml b/app/views/admin/hooks/index.html.haml index eed3ec74d60bb79caa89d1c83e365e3543b8feeb..1c14291b58ecadeb8840fcec3bc717e78f87e1be 100644 --- a/app/views/admin/hooks/index.html.haml +++ b/app/views/admin/hooks/index.html.haml @@ -11,4 +11,4 @@ = render 'shared/web_hooks/index', hooks: @hooks, hook_class: @hook.class -= render 'shared/plugins/index' += render 'shared/file_hooks/index' diff --git a/app/views/admin/runners/index.html.haml b/app/views/admin/runners/index.html.haml index f8ef7a45f7f7463cd4f815c1d6a3c1148320756a..818d265c76756807945f3e9e41609290e563d26b 100644 --- a/app/views/admin/runners/index.html.haml +++ b/app/views/admin/runners/index.html.haml @@ -57,24 +57,22 @@ %li.input-token %input.form-control.filtered-search{ search_filter_input_options('runners') } #js-dropdown-hint.filtered-search-input-dropdown-menu.dropdown-menu.hint-dropdown - %ul{ data: { dropdown: true } } - %li.filter-dropdown-item{ data: { action: 'submit' } } - = button_tag class: %w[btn btn-link] do - = sprite_icon('search') - %span - = _('Press Enter or click to search') %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } - %li.filter-dropdown-item + %li.filter-dropdown-item{ data: {hint: "#{'{{hint}}'}", tag: "#{'{{tag}}'}", action: "#{'{{hint === \'search\' ? \'submit\' : \'\' }}'}" } } = button_tag class: %w[btn btn-link] do -# Encapsulate static class name `{{icon}}` inside #{} to bypass -# haml lint's ClassAttributeWithStaticValue %svg %use{ 'xlink:href': "#{'{{icon}}'}" } %span.js-filter-hint - {{hint}} - %span.js-filter-tag.dropdown-light-content - {{tag}} - + {{formattedKey}} + #js-dropdown-operator.filtered-search-input-dropdown-menu.dropdown-menu + %ul.filter-dropdown{ data: { dropdown: true, dynamic: true } } + %li.filter-dropdown-item{ data: { value: "{{ title }}" } } + %button.btn.btn-link{ type: 'button' } + {{ title }} + %span.btn-helptext + {{ help }} #js-dropdown-admin-runner-status.filtered-search-input-dropdown-menu.dropdown-menu %ul{ data: { dropdown: true } } - Ci::Runner::AVAILABLE_STATUSES.each do |status| diff --git a/app/views/admin/users/_user.html.haml b/app/views/admin/users/_user.html.haml index ca5109614fcc152419141f0dc97fa80ccc238f7f..978e830d0e43d09fd1398b1b1b50297cb9b6af82 100644 --- a/app/views/admin/users/_user.html.haml +++ b/app/views/admin/users/_user.html.haml @@ -19,8 +19,8 @@ = link_to _('Edit'), edit_admin_user_path(user), id: "edit_#{dom_id(user)}", class: 'btn btn-default' - unless user == current_user %button.dropdown-new.btn.btn-default{ type: 'button', data: { toggle: 'dropdown' } } - = icon('cog') - = icon('caret-down') + = sprite_icon('settings') + = sprite_icon('chevron-down') %ul.dropdown-menu.dropdown-menu-right %li.dropdown-header = _('Settings') diff --git a/app/views/admin/users/index.html.haml b/app/views/admin/users/index.html.haml index 3c6ad899d1e764ff3a4a45aaaea4911ee5e6535e..ecbabab3e7f41eb7a18f85b6fe81a0f5385a6ded 100644 --- a/app/views/admin/users/index.html.haml +++ b/app/views/admin/users/index.html.haml @@ -9,7 +9,7 @@ = nav_link(html_options: { class: active_when(params[:filter].nil?) }) do = link_to admin_users_path do = s_('AdminUsers|Active') - %small.badge.badge-pill= limited_counter_with_delimiter(User.active) + %small.badge.badge-pill= limited_counter_with_delimiter(User.active_without_ghosts) = nav_link(html_options: { class: active_when(params[:filter] == 'admins') }) do = link_to admin_users_path(filter: "admins") do = s_('AdminUsers|Admins') diff --git a/app/views/clusters/clusters/_cluster.html.haml b/app/views/clusters/clusters/_cluster.html.haml index b89789e9915272d2eccb1c294c4c788e7464c2a5..04afc38a056fca4ec6b832726c6d532e347fe0da 100644 --- a/app/views/clusters/clusters/_cluster.html.haml +++ b/app/views/clusters/clusters/_cluster.html.haml @@ -3,7 +3,7 @@ .table-section.section-60 .table-mobile-header{ role: "rowheader" }= s_("ClusterIntegration|Kubernetes cluster") .table-mobile-content - = cluster.item_link(clusterable) + = cluster.item_link(clusterable, html_options: { data: { qa_selector: 'cluster', qa_cluster_name: cluster.name } }) - unless cluster.enabled? %span.badge.badge-danger Connection disabled .table-section.section-25 diff --git a/app/views/clusters/clusters/aws/_new.html.haml b/app/views/clusters/clusters/aws/_new.html.haml index d89e6965dac63bf3ccf197cbc7b87d40d9cecb37..5bbdadf83f3b045ec3557c4fb49ac2c3b0e2076d 100644 --- a/app/views/clusters/clusters/aws/_new.html.haml +++ b/app/views/clusters/clusters/aws/_new.html.haml @@ -11,6 +11,6 @@ 'role-arn' => @aws_role.role_arn, 'instance-types' => @instance_types, 'kubernetes-integration-help-path' => help_page_path('user/project/clusters/index'), - 'account-and-external-ids-help-path' => help_page_path('user/project/clusters/add_remove_clusters.md', anchor: 'eks-cluster'), - 'create-role-arn-help-path' => help_page_path('user/project/clusters/add_remove_clusters.md', anchor: 'eks-cluster'), + 'account-and-external-ids-help-path' => help_page_path('user/project/clusters/add_remove_clusters.md', anchor: 'new-eks-cluster'), + 'create-role-arn-help-path' => help_page_path('user/project/clusters/add_remove_clusters.md', anchor: 'new-eks-cluster'), 'external-link-icon' => icon('external-link') } } diff --git a/app/views/clusters/clusters/show.html.haml b/app/views/clusters/clusters/show.html.haml index 5beeaf7259a83f3f46fcae7c4da48950d3549ac0..4b295cd022d4d6dfe659e2fda101c51c8867700a 100644 --- a/app/views/clusters/clusters/show.html.haml +++ b/app/views/clusters/clusters/show.html.haml @@ -30,6 +30,7 @@ help_path: help_page_path('user/project/clusters/index.md', anchor: 'installing-applications'), ingress_help_path: help_page_path('user/project/clusters/index.md', anchor: 'getting-the-external-endpoint'), ingress_dns_help_path: help_page_path('user/project/clusters/index.md', anchor: 'manually-determining-the-external-endpoint'), + ingress_mod_security_help_path: help_page_path('user/clusters/applications.md', anchor: 'web-application-firewall-modsecurity'), environments_help_path: help_page_path('ci/environments', anchor: 'defining-environments'), clusters_help_path: help_page_path('user/project/clusters/index.md', anchor: 'deploying-to-a-kubernetes-cluster'), deploy_boards_help_path: help_page_path('user/project/deploy_boards.html', anchor: 'enabling-deploy-boards'), diff --git a/app/views/dashboard/projects/_projects.html.haml b/app/views/dashboard/projects/_projects.html.haml index 9e0e908e656a6d55bd1817868a807996f3f9714e..5122164dbcba7f0dbf7591d669b179fd8647c85b 100644 --- a/app/views/dashboard/projects/_projects.html.haml +++ b/app/views/dashboard/projects/_projects.html.haml @@ -1 +1 @@ -= render 'shared/projects/list', projects: @projects, ci: true, user: current_user += render 'shared/projects/list', projects: @projects, pipeline_status: Feature.enabled?(:dashboard_pipeline_status, default_enabled: true), user: current_user diff --git a/app/views/devise/shared/_experimental_separate_sign_up_flow_box.html.haml b/app/views/devise/shared/_experimental_separate_sign_up_flow_box.html.haml index 5d163d03c735882c12fd1a1897c95c23e5395701..4832861445b6b4c45b68139a275d8285fae606f6 100644 --- a/app/views/devise/shared/_experimental_separate_sign_up_flow_box.html.haml +++ b/app/views/devise/shared/_experimental_separate_sign_up_flow_box.html.haml @@ -1,14 +1,23 @@ - content_for(:page_title, _('Register for GitLab')) +- max_first_name_length = max_last_name_length = 127 - max_username_length = 255 .signup-box.p-3.mb-2 .signup-body = form_for(resource, as: "new_#{resource_name}", url: registration_path(resource_name), html: { class: "new_new_user gl-show-field-errors", "aria-live" => "assertive" }) do |f| .devise-errors.mt-0 = render "devise/shared/error_messages", resource: resource - = invisible_captcha + - if Feature.enabled?(:invisible_captcha) + = invisible_captcha + .name.form-row + .col.form-group + = f.label :first_name, _('First name'), for: 'new_user_first_name', class: 'label-bold' + = f.text_field :first_name, class: 'form-control top js-block-emoji js-validate-length', :data => { :max_length => max_first_name_length, :max_length_message => _("First Name is too long (maximum is %{max_length} characters).") % { max_length: max_first_name_length }, :qa_selector => 'new_user_firstname_field' }, required: true, title: _("This field is required.") + .col.form-group + = f.label :last_name, _('Last name'), for: 'new_user_last_name', class: 'label-bold' + = f.text_field :last_name, class: "form-control top js-block-emoji js-validate-length", :data => { :max_length => max_last_name_length, :max_length_message => _("Last Name is too long (maximum is %{max_length} characters).") % { max_length: max_last_name_length }, :qa_selector => 'new_user_lastname_field' }, required: true, title: _("This field is required.") .username.form-group = f.label :username, class: 'label-bold' - = f.text_field :username, class: "form-control middle js-block-emoji js-validate-length js-validate-username", :data => { :max_length => max_username_length, :max_length_message => s_("SignUp|Username is too long (maximum is %{max_length} characters).") % { max_length: max_username_length }, :qa_selector => 'new_user_username_field' }, pattern: Gitlab::PathRegex::NAMESPACE_FORMAT_REGEX_JS, required: true, title: _("Please create a username with only alphanumeric characters.") + = f.text_field :username, class: "form-control middle js-block-emoji js-validate-length js-validate-username", :data => { :max_length => max_username_length, :max_length_message => _("Username is too long (maximum is %{max_length} characters).") % { max_length: max_username_length }, :qa_selector => 'new_user_username_field' }, pattern: Gitlab::PathRegex::NAMESPACE_FORMAT_REGEX_JS, required: true, title: _("Please create a username with only alphanumeric characters.") %p.validation-error.gl-field-error-ignore.field-validation.mt-1.hide.cred= _('Username is already taken.') %p.validation-success.gl-field-error-ignore.field-validation.mt-1.hide.cgreen= _('Username is available.') %p.validation-pending.gl-field-error-ignore.field-validation.mt-1.hide= _('Checking username availability...') @@ -27,5 +36,8 @@ - accept_terms_label = _("I accept the %{terms_link}") % { terms_link: terms_link } = accept_terms_label.html_safe = render_if_exists 'devise/shared/email_opted_in', f: f + %div + - if show_recaptcha_sign_up? + = recaptcha_tags .submit-container.mt-3 = f.submit _("Register"), class: "btn-register btn btn-block btn-success mb-0 p-2", data: { qa_selector: 'new_user_register_button' } diff --git a/app/views/devise/shared/_signup_box.html.haml b/app/views/devise/shared/_signup_box.html.haml index 2cd77af6877a644a49cf7e4b4d2a0b8cae72f124..7c5b85c903c91dbf4e6b1bcabb1e9643e3cdb616 100644 --- a/app/views/devise/shared/_signup_box.html.haml +++ b/app/views/devise/shared/_signup_box.html.haml @@ -1,4 +1,4 @@ -- max_name_length = 128 +- max_name_length = 255 - max_username_length = 255 #register-pane.tab-pane.login-box{ role: 'tabpanel' } .login-body diff --git a/app/views/errors/_footer.html.haml b/app/views/errors/_footer.html.haml index e67a3a142f6dec1d3c5391473980bfead28665ec..bb9edc54b4bfd8cb7c285e4da0488058d31b3205 100644 --- a/app/views/errors/_footer.html.haml +++ b/app/views/errors/_footer.html.haml @@ -4,7 +4,7 @@ = link_to s_('Nav|Home'), root_path %li - if current_user - = link_to s_('Nav|Sign out and sign in with a different account'), destroy_user_session_path + = link_to s_('Nav|Sign out and sign in with a different account'), destroy_user_session_path, method: :post - else = link_to s_('Nav|Sign In / Register'), new_session_path(:user, redirect_to_referer: 'yes') %li diff --git a/app/views/explore/projects/_projects.html.haml b/app/views/explore/projects/_projects.html.haml index 35b32662b8a6b0c7915f1b6bdf5e12f7a817dff9..d819c4ea5540dbc0ec71a8730fb2cfcb38f17e1c 100644 --- a/app/views/explore/projects/_projects.html.haml +++ b/app/views/explore/projects/_projects.html.haml @@ -1,2 +1,2 @@ - is_explore_page = defined?(explore_page) && explore_page -= render 'shared/projects/list', projects: projects, user: current_user, explore_page: is_explore_page += render 'shared/projects/list', projects: projects, user: current_user, explore_page: is_explore_page, pipeline_status: Feature.enabled?(:dashboard_pipeline_status, default_enabled: true) diff --git a/app/views/groups/edit.html.haml b/app/views/groups/edit.html.haml index 0e78ce9f6569a6f1f6afad400d7a4904b609ae55..fe5a00e3be9bb524366e94d93261bb5650dfa8e0 100644 --- a/app/views/groups/edit.html.haml +++ b/app/views/groups/edit.html.haml @@ -53,4 +53,6 @@ .settings-content = render 'groups/settings/advanced' += render_if_exists 'shared/groups/max_pages_size_setting' + = render 'shared/confirm_modal', phrase: @group.path diff --git a/app/views/groups/group_members/_new_group_member.html.haml b/app/views/groups/group_members/_new_group_member.html.haml deleted file mode 100644 index 93dd8f48a60a82778ab3153f154d4920945d980c..0000000000000000000000000000000000000000 --- a/app/views/groups/group_members/_new_group_member.html.haml +++ /dev/null @@ -1,22 +0,0 @@ -= form_for @group_member, url: group_group_members_path(@group), html: { class: 'users-project-form users-group-form' } do |f| - .row - .col-md-4.col-lg-6 - = users_select_tag(:user_ids, group_member_select_options) - .form-text.text-muted.append-bottom-10 - Search for members by name, username, or email, or invite new ones using their email address. - - .col-md-3.col-lg-2 - = select_tag :access_level, options_for_select(GroupMember.access_level_roles, @group_member.access_level), class: "form-control project-access-select" - .form-text.text-muted.append-bottom-10 - = link_to "Read more", help_page_path("user/permissions") - about role permissions - - .col-md-3.col-lg-2 - .clearable-input - = text_field_tag :expires_at, nil, class: 'form-control js-access-expiration-date', placeholder: 'Expiration date' - %i.clear-icon.js-clear-input - .form-text.text-muted.append-bottom-10 - On this date, the member(s) will automatically lose access to this group and all of its projects. - - .col-md-2 - = f.submit 'Add to group', class: "btn btn-success btn-block", data: { qa_selector: 'add_to_group_button' } diff --git a/app/views/groups/group_members/index.html.haml b/app/views/groups/group_members/index.html.haml index 882fcc794217298b7a0673d7dc0bfae2d5735437..048edb80d99c2603eb75e802aa74f807b7c8a547 100644 --- a/app/views/groups/group_members/index.html.haml +++ b/app/views/groups/group_members/index.html.haml @@ -1,17 +1,28 @@ -- page_title _("Members") +- page_title _("Group members") - can_manage_members = can?(current_user, :admin_group_member, @group) - show_invited_members = can_manage_members && @invited_members.exists? - pending_active = params[:search_invited].present? +- total_count = @members.count + @group.shared_with_group_links.count .project-members-page.prepend-top-default %h4 - = _("Members") + = _("Group members") %hr - if can_manage_members - .project-members-new.append-bottom-default - %p.clearfix - = _("Add new member to %{strong_start}%{group_name}%{strong_end}").html_safe % { group_name: @group.name, strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe } - = render "new_group_member" + - if Feature.enabled?(:share_group_with_group, default_enabled: true) + %ul.nav-links.nav.nav-tabs.gitlab-tabs{ role: 'tablist' } + %li.nav-tab{ role: 'presentation' } + %a.nav-link.active{ href: '#invite-member-pane', id: 'invite-member-tab', data: { toggle: 'tab' }, role: 'tab' }= _("Invite member") + %li.nav-tab{ role: 'presentation' } + %a.nav-link{ href: '#invite-group-pane', id: 'invite-group-tab', data: { toggle: 'tab', qa_selector: 'invite_group_tab' }, role: 'tab' }= _("Invite group") + .tab-content.gitlab-tab-content + .tab-pane.active{ id: 'invite-member-pane', role: 'tabpanel' } + = render_invite_member_for_group(@group, @group_member.access_level) + - if Feature.enabled?(:share_group_with_group, default_enabled: true) + .tab-pane{ id: 'invite-group-pane', role: 'tabpanel' } + = render 'shared/members/invite_group', submit_url: group_group_links_path(@group), access_levels: GroupMember.access_level_roles, default_access_level: @group_member.access_level, group_link_field: 'shared_with_group_id', group_access_field: 'shared_group_access' + - else + = render_invite_member_for_group(@group, @group_member.access_level) = render 'shared/members/requests', membership_source: @group, requesters: @requesters @@ -19,10 +30,10 @@ %ul.nav-links.mobile-separator.nav.nav-tabs.clearfix %li.nav-item - = link_to "#existing_members", class: ["nav-link", ("active" unless pending_active)] , 'data-toggle' => 'tab' do + = link_to "#existing_shares", class: ["nav-link", ("active" unless pending_active)] , 'data-toggle' => 'tab' do %span - = _("Existing") - %span.badge.badge-pill= @members.total_count + = _("Existing shares") + %span.badge.badge-pill= total_count - if show_invited_members %li.nav-item = link_to "#invited_members", class: ["nav-link", ("active" if pending_active)], 'data-toggle' => 'tab' do @@ -31,7 +42,16 @@ %span.badge.badge-pill= @invited_members.total_count .tab-content - #existing_members.tab-pane{ :class => ("active" unless pending_active) } + #existing_shares.tab-pane{ :class => ("active" unless pending_active) } + - if @group.shared_with_group_links.any? + .card.card-without-border + .d-flex.flex-column.flex-md-row.row-content-block.second-block + %span.flex-grow-1.align-self-md-center.col-form-label + = _("Groups with access to %{strong_start}%{group_name}%{strong_end}").html_safe % { group_name: @group.name, strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe } + %ul.content-list.members-list{ data: { qa_selector: "groups_list" } } + - can_admin_member = can?(current_user, :admin_group_member, @group) + - @group.shared_with_group_links.each do |group_link| + = render 'shared/members/group', group_link: group_link, can_admin_member: can_admin_member, group_link_path: group_group_link_path(@group, group_link) .card.card-without-border .d-flex.flex-column.flex-md-row.row-content-block.second-block %span.flex-grow-1.align-self-md-center.col-form-label @@ -46,7 +66,7 @@ = label_tag '2fa', '2FA', class: 'col-form-label label-bold pr-md-2' = render 'shared/members/filter_2fa_dropdown' = render 'shared/members/sort_dropdown' - %ul.content-list.members-list + %ul.content-list.members-list{ data: { qa_selector: "members_list" } } = render partial: 'shared/members/member', collection: @members, as: :member = paginate @members, theme: 'gitlab' diff --git a/app/views/groups/settings/_pages_settings.html.haml b/app/views/groups/settings/_pages_settings.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..9e1932185dafb8392bf1fb0f20b247096fc7d34e --- /dev/null +++ b/app/views/groups/settings/_pages_settings.html.haml @@ -0,0 +1,5 @@ += form_for @group, html: { multipart: true, class: 'gl-show-field-errors' }, authenticity_token: true do |f| + = render_if_exists 'shared/pages/max_pages_size_input', form: f + + .prepend-top-10 + = f.submit s_('GitLabPages|Save'), class: 'btn btn-success' diff --git a/app/views/groups/settings/ci_cd/show.html.haml b/app/views/groups/settings/ci_cd/show.html.haml index 81bd15ed2871ab35eb47b1fb90e2d262da0607ae..8c9b859e127ab08bc32865f3411c93ad1c5f517d 100644 --- a/app/views/groups/settings/ci_cd/show.html.haml +++ b/app/views/groups/settings/ci_cd/show.html.haml @@ -44,8 +44,10 @@ = expanded ? _('Collapse') : _('Expand') %p - auto_devops_url = help_page_path('topics/autodevops/index') + - quickstart_url = help_page_path('topics/autodevops/quick_start_guide') - auto_devops_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: auto_devops_url } - = s_('GroupSettings|Auto DevOps will automatically build, test and deploy your application based on a predefined Continuous Integration and Delivery configuration. %{auto_devops_start}Learn more about Auto DevOps%{auto_devops_end}').html_safe % { auto_devops_start: auto_devops_start, auto_devops_end: '</a>'.html_safe } + - quickstart_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: quickstart_url } + = s_('AutoDevOps|Auto DevOps can automatically build, test, and deploy applications based on predefined continuous integration and delivery configuration. %{auto_devops_start}Learn more about Auto DevOps%{auto_devops_end} or use our %{quickstart_start}quick start guide%{quickstart_end} to get started right away.').html_safe % { auto_devops_start: auto_devops_start, auto_devops_end: '</a>'.html_safe, quickstart_start: quickstart_start, quickstart_end: '</a>'.html_safe } .settings-content = render 'groups/settings/ci_cd/auto_devops_form', group: @group diff --git a/app/views/import/github/new.html.haml b/app/views/import/github/new.html.haml index 518c44cc687f19178591e5ff03466fb75a29664b..e86d4236be817c27dcd1a26711032ab0bc63a18e 100644 --- a/app/views/import/github/new.html.haml +++ b/app/views/import/github/new.html.haml @@ -21,7 +21,7 @@ = form_tag personal_access_token_import_github_path, method: :post do .form-group %label.label-bold= _('Personal Access Token') - = text_field_tag :personal_access_token, '', class: 'form-control', placeholder: _('e.g. %{token}') % { token: '8d3f016698e...' } + = text_field_tag :personal_access_token, '', class: 'form-control', placeholder: _('e.g. %{token}') % { token: '8d3f016698e...' }, data: { qa_selector: 'personal_access_token_field' } %span.form-text.text-muted = import_github_personal_access_token_message @@ -29,4 +29,4 @@ .form-actions.d-flex.justify-content-end = link_to _('Cancel'), new_project_path, class: 'btn' - = submit_tag _('Authenticate'), class: 'btn btn-success ml-2' + = submit_tag _('Authenticate'), class: 'btn btn-success ml-2', data: { qa_selector: 'authenticate_button' } diff --git a/app/views/layouts/_broadcast.html.haml b/app/views/layouts/_broadcast.html.haml index ee3ca8243426c7818ce09d674f1fb5b98819a22d..9d7ad249ac833e2689b12831b5cb881dabb62e27 100644 --- a/app/views/layouts/_broadcast.html.haml +++ b/app/views/layouts/_broadcast.html.haml @@ -1,2 +1,4 @@ -- current_broadcast_messages&.each do |message| +- current_broadcast_banner_messages.each do |message| = broadcast_message(message) +- if Feature.enabled?(:broadcast_notification_type) + = broadcast_message(current_broadcast_notification_message) diff --git a/app/views/layouts/_flash.html.haml b/app/views/layouts/_flash.html.haml index de1caeaa50f5aa7f57275be661b013afcc5ccb9b..07c271be2f01dd97c91d58d7e769e8bd3241db6a 100644 --- a/app/views/layouts/_flash.html.haml +++ b/app/views/layouts/_flash.html.haml @@ -1,10 +1,12 @@ -# We currently only support `alert`, `notice`, `success`, 'toast' +- icons = {'alert' => 'error', 'notice' => 'information-o', 'success' => 'check-circle'}; .flash-container.flash-container-page.sticky - flash.each do |key, value| - if key == 'toast' && value .js-toast-message{ data: { message: value } } - elsif value %div{ class: "flash-#{key} mb-2" } + = sprite_icon(icons[key], size: 16, css_class: 'align-middle mr-1') unless icons[key].nil? %span= value %div{ class: "close-icon-wrapper js-close-icon" } = sprite_icon('close', size: 16, css_class: 'close-icon') diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml index 0060d8323b03e983630e4b9757d96658f730c29e..6b336f3eba2586bcd6f982323227ec482d5f2319 100644 --- a/app/views/layouts/_head.html.haml +++ b/app/views/layouts/_head.html.haml @@ -46,7 +46,7 @@ = stylesheet_link_tag "application", media: "all" = stylesheet_link_tag "print", media: "print" - = stylesheet_link_tag "test", media: "all" if Rails.env.test? + = stylesheet_link_tag "disable_animations", media: "all" if Rails.env.test? || Gitlab.config.gitlab['disable_animations'] = stylesheet_link_tag 'performance_bar' if performance_bar_enabled? = stylesheet_link_tag "highlight/themes/#{user_color_scheme}", media: "all" diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml index f4ab491a38ef60904e06776d46b12ed51555f883..7af190f5a0b8fa7b6a598c14cdcc2adb7eaac564 100644 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -1,5 +1,8 @@ +- page_classes = page_class << @html_class +- page_classes = page_classes.flatten.compact + !!! 5 -%html{ lang: I18n.locale, class: page_class } +%html{ lang: I18n.locale, class: page_classes } = render "layouts/head" %body{ class: "#{user_application_theme} #{@body_class} #{client_class_list}", data: body_data } = render "layouts/init_auto_complete" if @gfm_form diff --git a/app/views/layouts/header/_current_user_dropdown.html.haml b/app/views/layouts/header/_current_user_dropdown.html.haml index 88803f982e8821422e4a73c3124ace17ee9d1b4f..84906c305a78219b2c1e0bab8463e1d745349ff2 100644 --- a/app/views/layouts/header/_current_user_dropdown.html.haml +++ b/app/views/layouts/header/_current_user_dropdown.html.haml @@ -47,4 +47,4 @@ - if current_user_menu?(:sign_out) %li.divider %li - = link_to _("Sign out"), destroy_user_session_path, class: "sign-out-link", data: { qa_selector: 'sign_out_link' } + = link_to _("Sign out"), destroy_user_session_path, method: :post, class: "sign-out-link", data: { qa_selector: 'sign_out_link' } diff --git a/app/views/layouts/instance_statistics.html.haml b/app/views/layouts/instance_statistics.html.haml index bebd9c4536f91233a8b4fe2d82239fe9ef25af21..1de6b385c86ee87a227d309855b6dcffc9cd824a 100644 --- a/app/views/layouts/instance_statistics.html.haml +++ b/app/views/layouts/instance_statistics.html.haml @@ -1,5 +1,5 @@ -- page_title _('Instance Statistics') -- header_title _('Instance Statistics'), instance_statistics_root_path +- page_title _('Analytics') +- header_title _('Analytics'), instance_statistics_root_path - nav 'instance_statistics' - @left_sidebar = true diff --git a/app/views/layouts/nav/_analytics_link.html.haml b/app/views/layouts/nav/_analytics_link.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..f481aeecc1ba62ce13622c26899933f4e4cd4886 --- /dev/null +++ b/app/views/layouts/nav/_analytics_link.html.haml @@ -0,0 +1,4 @@ +- return unless dashboard_nav_link?(:analytics) += nav_link(controller: [:dev_ops_score, :cohorts], html_options: { class: "d-none d-xl-block"}) do + = link_to instance_statistics_root_path, class: 'chart-icon', title: _('Analytics'), aria: { label: _('Analytics') }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do + = sprite_icon('chart', size: 18) diff --git a/app/views/layouts/nav/_dashboard.html.haml b/app/views/layouts/nav/_dashboard.html.haml index 9a839765286e054b7feee4cb68ab59b03a85e655..379ba976040f586681f02c7387401766d6d1f7ed 100644 --- a/app/views/layouts/nav/_dashboard.html.haml +++ b/app/views/layouts/nav/_dashboard.html.haml @@ -47,10 +47,7 @@ %li.dropdown = render_if_exists 'dashboard/nav_link_list' - - if can?(current_user, :read_instance_statistics) - = nav_link(controller: [:dev_ops_score, :cohorts]) do - = link_to instance_statistics_root_path do - = _('Instance Statistics') + - if current_user.admin? = nav_link(controller: 'admin/dashboard') do = link_to admin_root_path, class: 'admin-icon qa-admin-area-link d-xl-none' do @@ -58,7 +55,7 @@ - if Feature.enabled?(:user_mode_in_session) - if header_link?(:admin_mode) = nav_link(controller: 'admin/sessions') do - = link_to destroy_admin_session_path, class: 'd-lg-none lock-open-icon' do + = link_to destroy_admin_session_path, method: :post, class: 'd-lg-none lock-open-icon' do = _('Leave Admin Mode') - elsif current_user.admin? = nav_link(controller: 'admin/sessions') do @@ -69,6 +66,8 @@ = link_to sherlock_transactions_path, class: 'admin-icon' do = _('Sherlock Transactions') + = render_if_exists 'layouts/nav/analytics_link' + - if current_user.admin? = nav_link(controller: 'admin/dashboard', html_options: { class: "d-none d-xl-block"}) do = link_to admin_root_path, class: 'admin-icon qa-admin-area-link', title: _('Admin Area'), aria: { label: _('Admin Area') }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do diff --git a/app/views/layouts/nav/sidebar/_analytics_link.html.haml b/app/views/layouts/nav/sidebar/_analytics_link.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..9e5ae422e2dc3fab56da8fcb6c8d4aeef3369f19 --- /dev/null +++ b/app/views/layouts/nav/sidebar/_analytics_link.html.haml @@ -0,0 +1,4 @@ +- return unless dashboard_nav_link?(:analytics) += nav_link(controller: [:dev_ops_score, :cohorts]) do + = link_to instance_statistics_root_path, class: 'd-xl-none' do + = _('Analytics') diff --git a/app/views/layouts/nav/sidebar/_group.html.haml b/app/views/layouts/nav/sidebar/_group.html.haml index a027dca1b560ec15c163c8d3469b40f827b778e1..88bb0a9748769d15384bed5f8e85e8916637df14 100644 --- a/app/views/layouts/nav/sidebar/_group.html.haml +++ b/app/views/layouts/nav/sidebar/_group.html.haml @@ -44,7 +44,7 @@ - if group_sidebar_link?(:contribution_analytics) = nav_link(path: 'analytics#show') do - = link_to group_analytics_path(@group), title: _('Contribution Analytics'), data: { placement: 'right', qa_selector: 'contribution_analytics_link' } do + = link_to group_contribution_analytics_path(@group), title: _('Contribution Analytics'), data: { placement: 'right', qa_selector: 'contribution_analytics_link' } do %span = _('Contribution Analytics') diff --git a/app/views/layouts/nav/sidebar/_instance_statistics.html.haml b/app/views/layouts/nav/sidebar/_instance_statistics.html.haml index 0a84e9524423d6890f4fabe8c3c949f9bd08a8b2..979d98ec3824cf4625b702a31d91b7fe15adada9 100644 --- a/app/views/layouts/nav/sidebar/_instance_statistics.html.haml +++ b/app/views/layouts/nav/sidebar/_instance_statistics.html.haml @@ -1,34 +1,11 @@ .nav-sidebar{ class: ("sidebar-collapsed-desktop" if collapsed_sidebar?) } .nav-sidebar-inner-scroll .context-header - = link_to instance_statistics_root_path, title: _('Instance Statistics') do + = link_to instance_statistics_root_path, title: _('Analytics') do .avatar-container.s40.settings-avatar = sprite_icon('chart', size: 24) - .sidebar-context-title= _('Instance Statistics') + .sidebar-context-title= _('Analytics') %ul.sidebar-top-level-items - = nav_link(controller: :dev_ops_score) do - = link_to instance_statistics_dev_ops_score_index_path do - .nav-icon-container - = sprite_icon('comment') - %span.nav-item-name - = _('DevOps Score') - %ul.sidebar-sub-level-items.is-fly-out-only - = nav_link(controller: :dev_ops_score, html_options: { class: "fly-out-top-item" } ) do - = link_to instance_statistics_dev_ops_score_index_path do - %strong.fly-out-top-item-name - = _('DevOps Score') - - - if Gitlab::CurrentSettings.usage_ping_enabled - = nav_link(controller: :cohorts) do - = link_to instance_statistics_cohorts_path do - .nav-icon-container - = sprite_icon('users') - %span.nav-item-name - = _('Cohorts') - %ul.sidebar-sub-level-items.is-fly-out-only - = nav_link(controller: :cohorts, html_options: { class: "fly-out-top-item" } ) do - = link_to instance_statistics_cohorts_path do - %strong.fly-out-top-item-name - = _('Cohorts') + = render 'layouts/nav/sidebar/instance_statistics_links' = render 'shared/sidebar_toggle_button' diff --git a/app/views/layouts/nav/sidebar/_instance_statistics_links.html.haml b/app/views/layouts/nav/sidebar/_instance_statistics_links.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..ee2c83dc31ee14d8ff08cd2013a178b0a3582fb5 --- /dev/null +++ b/app/views/layouts/nav/sidebar/_instance_statistics_links.html.haml @@ -0,0 +1,25 @@ +- return unless can?(current_user, :read_instance_statistics) += nav_link(controller: :dev_ops_score) do + = link_to instance_statistics_dev_ops_score_index_path do + .nav-icon-container + = sprite_icon('comment') + %span.nav-item-name + = _('DevOps Score') + %ul.sidebar-sub-level-items.is-fly-out-only + = nav_link(controller: :dev_ops_score, html_options: { class: "fly-out-top-item" } ) do + = link_to instance_statistics_dev_ops_score_index_path do + %strong.fly-out-top-item-name + = _('DevOps Score') + +- if Gitlab::CurrentSettings.usage_ping_enabled + = nav_link(controller: :cohorts) do + = link_to instance_statistics_cohorts_path do + .nav-icon-container + = sprite_icon('users') + %span.nav-item-name + = _('Cohorts') + %ul.sidebar-sub-level-items.is-fly-out-only + = nav_link(controller: :cohorts, html_options: { class: "fly-out-top-item" } ) do + = link_to instance_statistics_cohorts_path do + %strong.fly-out-top-item-name + = _('Cohorts') diff --git a/app/views/layouts/nav/sidebar/_project.html.haml b/app/views/layouts/nav/sidebar/_project.html.haml index 1e2556aecc18abbc82d864e9360e56bf1d2d0a8f..3464cc1ea07258a5d156e47859c617c2e8a0b882 100644 --- a/app/views/layouts/nav/sidebar/_project.html.haml +++ b/app/views/layouts/nav/sidebar/_project.html.haml @@ -290,6 +290,8 @@ = render_if_exists 'layouts/nav/sidebar/project_packages_link' + = render_if_exists 'layouts/nav/sidebar/project_analytics_link' # EE-specific + - if project_nav_tab? :wiki - wiki_url = project_wiki_path(@project, :home) = nav_link(controller: :wikis) do diff --git a/app/views/profiles/_name.html.haml b/app/views/profiles/_name.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..87f1634b4f33250d5ebfd9c83c00485805ad7e8a --- /dev/null +++ b/app/views/profiles/_name.html.haml @@ -0,0 +1,5 @@ +- if user.read_only_attribute?(:name) + = form.text_field :name, required: true, readonly: true, wrapper: { class: 'col-md-9 qa-full-name rspec-full-name' }, + help: s_("Profiles|Your name was automatically set based on your %{provider_label} account, so people you know can recognize you") % { provider_label: attribute_provider_label(:name) } +- else + = form.text_field :name, label: s_('Profiles|Full name'), required: true, title: s_("Profiles|Using emojis in names seems fun, but please try to set a status message instead"), wrapper: { class: 'col-md-9 qa-full-name rspec-full-name' }, help: s_("Profiles|Enter your name, so people you know can recognize you") diff --git a/app/views/profiles/active_sessions/_active_session.html.haml b/app/views/profiles/active_sessions/_active_session.html.haml index bb31049111ccbebda2e785fdb9375124b89d06ee..f3ad0c4c8adae53e44b3762b8ff146fcf2e05330 100644 --- a/app/views/profiles/active_sessions/_active_session.html.haml +++ b/app/views/profiles/active_sessions/_active_session.html.haml @@ -24,3 +24,9 @@ %strong= _('Signed in') = s_('ProfileSession|on') = l(active_session.created_at, format: :short) + + - unless is_current_session + .float-right + = link_to profile_active_session_path(active_session.public_id), data: { confirm: _('Are you sure? The device will be signed out of GitLab.') }, method: :delete, class: "btn btn-danger prepend-left-10" do + %span.sr-only= _('Revoke') + = _('Revoke') diff --git a/app/views/profiles/notifications/show.html.haml b/app/views/profiles/notifications/show.html.haml index 1f311e9a4a4eba51b8d89efe99d13f2aabac0b24..73f6a821b51acf7e127bb34b861d21f187f54f06 100644 --- a/app/views/profiles/notifications/show.html.haml +++ b/app/views/profiles/notifications/show.html.haml @@ -39,12 +39,12 @@ %hr %h5 - = _('Groups (%{count})') % { count: @group_notifications.count } + = _('Groups (%{count})') % { count: @group_notifications.size } %div - @group_notifications.each do |setting| = render 'group_settings', setting: setting, group: setting.source %h5 - = _('Projects (%{count})') % { count: @project_notifications.count } + = _('Projects (%{count})') % { count: @project_notifications.size } %p.account-well = _('To specify the notification level per project of a group you belong to, you need to visit project page and change notification level there.') .append-bottom-default diff --git a/app/views/profiles/preferences/show.html.haml b/app/views/profiles/preferences/show.html.haml index bf76b7379dd362022c8370d88db7c5b31dcadff8..93acd6f550b4697f296ac25cff2e16fd0609076e 100644 --- a/app/views/profiles/preferences/show.html.haml +++ b/app/views/profiles/preferences/show.html.haml @@ -61,10 +61,14 @@ = f.select :project_view, project_view_choices, {}, class: 'select2' .form-text.text-muted = s_('Preferences|Choose what content you want to see on a project’s overview page.') + .form-group.form-check + = f.check_box :render_whitespace_in_code, class: 'form-check-input' + = f.label :render_whitespace_in_code, class: 'form-check-label' do + = s_('Preferences|Render whitespace characters in the Web IDE') .form-group.form-check = f.check_box :show_whitespace_in_diffs, class: 'form-check-input' = f.label :show_whitespace_in_diffs, class: 'form-check-label' do - = s_('Preferences|Show whitespace in diffs') + = s_('Preferences|Show whitespace changes in diffs') .col-sm-12 %hr diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml index cfad274f91db53156d09e8e1c6aa527edceb13aa..49533c18c8f0db58a8b9bfebd1ad59279b3e9b01 100644 --- a/app/views/profiles/show.html.haml +++ b/app/views/profiles/show.html.haml @@ -88,11 +88,7 @@ = s_("Profiles|Some options are unavailable for LDAP accounts") .col-lg-8 .row - - if @user.read_only_attribute?(:name) - = f.text_field :name, required: true, readonly: true, wrapper: { class: 'col-md-9 qa-full-name rspec-full-name' }, - help: s_("Profiles|Your name was automatically set based on your %{provider_label} account, so people you know can recognize you") % { provider_label: attribute_provider_label(:name) } - - else - = f.text_field :name, label: s_('Profiles|Full name'), required: true, title: s_("Profiles|Using emojis in names seems fun, but please try to set a status message instead"), wrapper: { class: 'col-md-9 qa-full-name rspec-full-name' }, help: s_("Profiles|Enter your name, so people you know can recognize you") + = render 'profiles/name', form: f, user: @user = f.text_field :id, readonly: true, label: s_('Profiles|User ID'), wrapper: { class: 'col-md-3' } = f.select :role, ::User.roles.keys.map { |role| [role.titleize, role] }, { prompt: _('Select your role') }, required: true, class: 'input-md' diff --git a/app/views/projects/_merge_request_merge_suggestions_settings.html.haml b/app/views/projects/_merge_request_merge_suggestions_settings.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..06bb9056e61540bb5da29b139638ffac577e4163 --- /dev/null +++ b/app/views/projects/_merge_request_merge_suggestions_settings.html.haml @@ -0,0 +1,17 @@ +- form = local_assigns.fetch(:form) + +.form-group + %b= s_('ProjectSettings|Merge suggestions') + %p.text-secondary + = s_('ProjectSettings|The commit message used to apply merge request suggestions') + = link_to icon('question-circle'), + help_page_path('user/discussions/index.md', + anchor: 'configure-the-commit-message-for-applied-suggestions'), + target: '_blank' + .mb-2 + = form.text_field :suggestion_commit_message, class: 'form-control mb-2', placeholder: Suggestions::ApplyService::DEFAULT_SUGGESTION_COMMIT_MESSAGE + %p.form-text.text-muted + = s_('ProjectSettings|The variables GitLab supports:') + - Suggestions::ApplyService::PLACEHOLDERS.keys.each do |placeholder| + %code + = "%{#{placeholder}}".html_safe diff --git a/app/views/projects/_merge_request_settings.html.haml b/app/views/projects/_merge_request_settings.html.haml index f2ba38387a32d8aaf85168bfbc374afa7db22a13..dc3a3fcc6473cc0c3f6aaf397eb2fe1834ed90de 100644 --- a/app/views/projects/_merge_request_settings.html.haml +++ b/app/views/projects/_merge_request_settings.html.haml @@ -5,3 +5,5 @@ = render 'projects/merge_request_merge_options_settings', project: @project, form: form = render 'projects/merge_request_merge_checks_settings', project: @project, form: form + += render 'projects/merge_request_merge_suggestions_settings', project: @project, form: form diff --git a/app/views/projects/_merge_request_settings_description_text.html.haml b/app/views/projects/_merge_request_settings_description_text.html.haml index 42964c900b3e8c7f58fc8145b457ed688225e1c2..dc9dc92675d5615e51776ac3f663a0b7824bd273 100644 --- a/app/views/projects/_merge_request_settings_description_text.html.haml +++ b/app/views/projects/_merge_request_settings_description_text.html.haml @@ -1 +1 @@ -%p= s_('ProjectSettings|Choose your merge method, merge options, and merge checks.') +%p= s_('ProjectSettings|Choose your merge method, merge options, merge checks, and merge suggestions.') diff --git a/app/views/projects/blame/show.html.haml b/app/views/projects/blame/show.html.haml index 30fe5622ebdb9f8f0f16ddbaa9b28974c93ce5f4..b17207c0da68814d9314b6aa5779e97eac595a04 100644 --- a/app/views/projects/blame/show.html.haml +++ b/app/views/projects/blame/show.html.haml @@ -20,8 +20,14 @@ .commit-row-title %span.item-title.str-truncated-100 = link_to commit.title, project_commit_path(@project, commit.id), class: "cdark", title: commit.title - .float-right - = link_to commit.short_id, project_commit_path(@project, commit), class: "commit-sha" + %span + - previous_commit_id = commit.parent_id + - if previous_commit_id + = link_to project_blame_path(@project, tree_join(previous_commit_id, @path)), + title: _('View blame prior to this change'), + aria: { label: _('View blame prior to this change') }, + data: { toggle: 'tooltip', placement: 'right', container: 'body' } do + = sprite_icon('doc-versions', size: 16, css_class: 'doc-versions align-text-bottom') .light = commit_author_link(commit, avatar: false) diff --git a/app/views/projects/blob/_render_error.html.haml b/app/views/projects/blob/_render_error.html.haml index 9eef6cafd04c955fc355f05c1d5f02d7c220eedb..1ff68cd2d11d866fdba56cfdb88632405db45d07 100644 --- a/app/views/projects/blob/_render_error.html.haml +++ b/app/views/projects/blob/_render_error.html.haml @@ -3,5 +3,5 @@ The #{viewer.switcher_title} could not be displayed because #{blob_render_error_reason(viewer)}. You can - = blob_render_error_options(viewer).to_sentence(two_words_connector: ' or ', last_word_connector: ', or ').html_safe + = Gitlab::Utils.to_exclusive_sentence(blob_render_error_options(viewer)).html_safe instead. diff --git a/app/views/projects/blob/viewers/_contributing.html.haml b/app/views/projects/blob/viewers/_contributing.html.haml index c78f04c9c7c80f8d83eb98a6775d81b87ade8714..546c064c06fd7ac5edeb0e5f80bda3fbfcd6bedd 100644 --- a/app/views/projects/blob/viewers/_contributing.html.haml +++ b/app/views/projects/blob/viewers/_contributing.html.haml @@ -4,6 +4,6 @@ After you've reviewed these contribution guidelines, you'll be all set to - options = contribution_options(viewer.project) - if options.any? = succeed '.' do - = options.to_sentence(two_words_connector: ' or ', last_word_connector: ', or ').html_safe + = Gitlab::Utils.to_exclusive_sentence(options).html_safe - else contribute to this project. diff --git a/app/views/projects/ci/lints/_create.html.haml b/app/views/projects/ci/lints/_create.html.haml index 59b5b9f8a301bc4788db3f2de2523bd964b440fd..d65c06aa2a49b9868d8b36ec80c1ba8a76e1b4b8 100644 --- a/app/views/projects/ci/lints/_create.html.haml +++ b/app/views/projects/ci/lints/_create.html.haml @@ -1,8 +1,8 @@ - if @status - %p - %b= _("Status:") - = _("syntax is correct") - %i.fa.fa-ok.correct-syntax + .bs-callout.bs-callout-success + %p + %b= _("Status:") + = _("syntax is correct") .table-holder %table.table.table-bordered @@ -40,9 +40,10 @@ %b= _("Allowed to fail") - else - %p - %b= _("Status:") - = _("syntax is incorrect") - %i.fa.fa-remove.incorrect-syntax - %b= _("Error:") - = @error + .bs-callout.bs-callout-danger + %p + %b= _("Status:") + = _("syntax is incorrect") + %pre + - @errors.each do |message| + %p= message diff --git a/app/views/projects/commits/_commit.html.haml b/app/views/projects/commits/_commit.html.haml index 3a9c7a8bec5b28ded6c5e1884894339e0fb78a72..8b659034fe64546f22c98c3e4e1c49481de0d61d 100644 --- a/app/views/projects/commits/_commit.html.haml +++ b/app/views/projects/commits/_commit.html.haml @@ -21,9 +21,9 @@ .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 js-onboarding-commit-item" + = link_to commit.title, project_commit_path(project, commit.id, merge_request_iid: merge_request.iid), class: ["commit-row-message item-title js-onboarding-commit-item", ("font-italic" if commit.message.empty?)] - else - = link_to_markdown_field(commit, :title, link, class: "commit-row-message item-title js-onboarding-commit-item") + = link_to_markdown_field(commit, :title, link, class: "commit-row-message item-title js-onboarding-commit-item #{"font-italic" if commit.message.empty?}") %span.commit-row-message.d-inline.d-sm-none · = commit.short_id diff --git a/app/views/projects/cycle_analytics/show.html.haml b/app/views/projects/cycle_analytics/show.html.haml index 1691af9dfddf2acebc9fed378879884624a96ee0..8bbe4e66c50269e380212060468f6e63cb56a6dc 100644 --- a/app/views/projects/cycle_analytics/show.html.haml +++ b/app/views/projects/cycle_analytics/show.html.haml @@ -50,7 +50,7 @@ %i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: _("The collection of events added to the data gathered for that stage."), "aria-hidden" => "true" } %li.total-time-header.pr-5.text-right %span.stage-name.font-weight-bold - {{ __('Total Time') }} + {{ __('Time') }} %i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: _("The time taken by each data entry gathered by that stage."), "aria-hidden" => "true" } .stage-panel-body %nav.stage-nav diff --git a/app/views/projects/default_branch/_show.html.haml b/app/views/projects/default_branch/_show.html.haml index 59efcde5825d3e20b1366ede85896eed7ab06b81..6a09004143ecc95be4f3bf1d419db9262dc32d73 100644 --- a/app/views/projects/default_branch/_show.html.haml +++ b/app/views/projects/default_branch/_show.html.haml @@ -9,13 +9,23 @@ = _('Select the branch you want to set as the default for this project. All merge requests and commits will automatically be made against this branch unless you specify a different one.') .settings-content - - if @project.empty_repo? - .text-secondary - = _('A default branch cannot be chosen for an empty project.') - - else - = form_for [@project.namespace.becomes(Namespace), @project], remote: true, html: { multipart: true, anchor: 'default-branch-settings' }, authenticity_token: true do |f| - %fieldset + = form_for [@project.namespace.becomes(Namespace), @project], remote: true, html: { multipart: true, anchor: 'default-branch-settings' }, authenticity_token: true do |f| + %fieldset + - if @project.empty_repo? + .text-secondary + = _('A default branch cannot be chosen for an empty project.') + - else .form-group = f.label :default_branch, "Default Branch", class: 'label-bold' = f.select(:default_branch, @project.repository.branch_names, {}, {class: 'select2 select-wide'}) - = f.submit 'Save changes', class: "btn btn-success" + + .form-group + .form-check + = f.check_box :autoclose_referenced_issues, class: 'form-check-input' + = f.label :autoclose_referenced_issues, class: 'form-check-label' do + %strong= _("Auto-close referenced issues on default branch") + .form-text.text-muted + = _("Issues referenced by merge requests and commits within the default branch will be closed automatically") + = link_to icon('question-circle'), help_page_path('user/project/issues/managing_issues.html', anchor: 'disabling-automatic-issue-closing'), target: '_blank' + + = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/projects/diffs/_viewer.html.haml b/app/views/projects/diffs/_viewer.html.haml index 5c4d1760871ec80bbfd9baf25b1ae7c54b16b469..37ff03b4a593745caeea28b7cfc759fccc2ee9eb 100644 --- a/app/views/projects/diffs/_viewer.html.haml +++ b/app/views/projects/diffs/_viewer.html.haml @@ -3,8 +3,6 @@ .diff-viewer{ data: { type: viewer.type }, class: ('hidden' if hidden) } - if viewer.render_error = render 'projects/diffs/render_error', viewer: viewer - - elsif viewer.collapsed? - = render 'projects/diffs/collapsed', viewer: viewer - else - viewer.prepare! diff --git a/app/views/projects/diffs/_collapsed.html.haml b/app/views/projects/diffs/viewers/_collapsed.html.haml similarity index 100% rename from app/views/projects/diffs/_collapsed.html.haml rename to app/views/projects/diffs/viewers/_collapsed.html.haml diff --git a/app/views/projects/environments/_pin_button.html.haml b/app/views/projects/environments/_pin_button.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..5c7bfc2b17b1a27a478e6bd37a6773c7034a4c6d --- /dev/null +++ b/app/views/projects/environments/_pin_button.html.haml @@ -0,0 +1,3 @@ +- if environment.auto_stop_at? && environment.available? + = button_to cancel_auto_stop_project_environment_path(environment.project, environment), class: 'btn btn-secondary has-tooltip', title: _('Prevent environment from auto-stopping') do + = sprite_icon('thumbtack') diff --git a/app/views/projects/environments/show.html.haml b/app/views/projects/environments/show.html.haml index 62b1c14079474a831f912c92a14af01cf2eb2202..ff78abfddf441f4e01d697298f0eee297f57d6ca 100644 --- a/app/views/projects/environments/show.html.haml +++ b/app/views/projects/environments/show.html.haml @@ -32,9 +32,14 @@ = button_to stop_project_environment_path(@project, @environment), class: 'btn btn-danger has-tooltip', method: :post do = s_('Environments|Stop environment') -.top-area - %h3.page-title= @environment.name - .nav-controls.ml-auto.my-2 +.top-area.justify-content-between + .d-flex + %h3.page-title= @environment.name + - if @environment.auto_stop_at? + %p.align-self-end.prepend-left-8 + = s_('Environments|Auto stops %{auto_stop_time}').html_safe % {auto_stop_time: time_ago_with_tooltip(@environment.auto_stop_at)} + .nav-controls.my-2 + = render 'projects/environments/pin_button', environment: @environment = render 'projects/environments/terminal_button', environment: @environment = render 'projects/environments/external_url', environment: @environment = render 'projects/environments/metrics_button', environment: @environment diff --git a/app/views/projects/graphs/charts.html.haml b/app/views/projects/graphs/charts.html.haml index 2a2ccf8a6de9c57b42a1541a9f5065a12745f1ee..93a43b5d1eacca29ec0d5600d22b7226692d1aed 100644 --- a/app/views/projects/graphs/charts.html.haml +++ b/app/views/projects/graphs/charts.html.haml @@ -4,6 +4,9 @@ %h4.sub-header = _("Programming languages used in this repository") + %p + = _("Measured in bytes of code. Excludes generated and vendored code.") + .row .col-md-4 %ul.bordered-list diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml index 17f6fe95f10b6bae911c02215603c2cce9aece97..9062f2097b80977a93596e25486f2bf1db4dfb1c 100644 --- a/app/views/projects/issues/show.html.haml +++ b/app/views/projects/issues/show.html.haml @@ -71,6 +71,9 @@ = edited_time_ago_with_tooltip(@issue, placement: 'bottom', html_class: 'issue-edited-ago js-issue-edited-ago') + - if @issue.sentry_issue.present? + #js-sentry-error-stack-trace{ data: error_details_data(@project, @issue.sentry_issue.sentry_issue_identifier) } + = render_if_exists 'projects/issues/related_issues' #js-related-merge-requests{ data: { endpoint: expose_path(api_v4_projects_issues_related_merge_requests_path(id: @project.id, issue_iid: @issue.iid)), project_namespace: @project.namespace.path, project_path: @project.path } } diff --git a/app/views/projects/pages/_https_only.html.haml b/app/views/projects/pages/_https_only.html.haml deleted file mode 100644 index d8c4a5f0a5d9232e3a4f54d291c44e81094e3d69..0000000000000000000000000000000000000000 --- a/app/views/projects/pages/_https_only.html.haml +++ /dev/null @@ -1,11 +0,0 @@ -= form_for @project, url: namespace_project_pages_path(@project.namespace.becomes(Namespace), @project), html: { class: 'inline', title: pages_https_only_title } do |f| - .form-group - .form-check - = f.check_box :pages_https_only, class: 'form-check-input', disabled: pages_https_only_disabled? - = f.label :pages_https_only, class: pages_https_only_label_class do - %strong - = s_('GitLabPages|Force HTTPS (requires valid certificates)') - - - unless pages_https_only_disabled? - .prepend-top-10 - = f.submit s_('GitLabPages|Save'), class: 'btn btn-success' diff --git a/app/views/projects/pages/_pages_settings.html.haml b/app/views/projects/pages/_pages_settings.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..58eddf630f4708408d26544284b35e602888b1a3 --- /dev/null +++ b/app/views/projects/pages/_pages_settings.html.haml @@ -0,0 +1,13 @@ += form_for @project, url: namespace_project_pages_path(@project.namespace.becomes(Namespace), @project), html: { class: 'inline', title: pages_https_only_title } do |f| + - if Gitlab.config.pages.external_http || Gitlab.config.pages.external_https + = render_if_exists 'shared/pages/max_pages_size_input', form: f + + .form-group + .form-check + = f.check_box :pages_https_only, class: 'form-check-input', disabled: pages_https_only_disabled? + = f.label :pages_https_only, class: pages_https_only_label_class do + %strong + = s_('GitLabPages|Force HTTPS (requires valid certificates)') + + .prepend-top-10 + = f.submit s_('GitLabPages|Save'), class: 'btn btn-success' diff --git a/app/views/projects/pages/_ssl_limitations_warning.html.haml b/app/views/projects/pages/_ssl_limitations_warning.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..7188e169824d6aa7eb3aa5bc15ce04e2a306e3c7 --- /dev/null +++ b/app/views/projects/pages/_ssl_limitations_warning.html.haml @@ -0,0 +1,7 @@ +.bs-callout.bs-callout-warning + %i.fa.fa-warning + %strong= _("Warning:") + - pages_host = Gitlab.config.pages.host + = s_("GitLabPages|When using Pages under the general domain of a GitLab instance (%{pages_host}), you cannot use HTTPS with sub-subdomains. This means that if your username/groupname contains a dot it will not work. This is a limitation of the HTTP Over TLS protocol. HTTP pages will continue to work provided you don't redirect HTTP to HTTPS.").html_safe % { pages_host: pages_host } + + %strong= external_link(s_("GitLabPages|Learn more."), "https://docs.gitlab.com/ee/user/project/pages/introduction.html#limitations") diff --git a/app/views/projects/pages/show.html.haml b/app/views/projects/pages/show.html.haml index 3ec875978495cd9bc024738d4511c481b41a9fe2..4b7810ea357527757bbfdf0485470a2fc330453a 100644 --- a/app/views/projects/pages/show.html.haml +++ b/app/views/projects/pages/show.html.haml @@ -10,11 +10,12 @@ %p.light = s_('GitLabPages|With GitLab Pages you can host your static websites on GitLab. Combined with the power of GitLab CI and the help of GitLab Runner you can deploy static pages for your individual projects, your user or your group.') - - if Gitlab.config.pages.external_https - = render 'https_only' + + = render 'pages_settings' %hr.clearfix + = render 'ssl_limitations_warning' if @project.pages_subdomain.include?(".") = render 'access' = render 'use' - if Gitlab.config.pages.external_http || Gitlab.config.pages.external_https diff --git a/app/views/projects/project_members/_groups.html.haml b/app/views/projects/project_members/_groups.html.haml index 00321014f911b6d7be4ac50f6d10028f617cf031..353c36d0fedd58b2010100afd9fe6ba6ae12211d 100644 --- a/app/views/projects/project_members/_groups.html.haml +++ b/app/views/projects/project_members/_groups.html.haml @@ -3,4 +3,6 @@ = _("Groups with access to <strong>%{project_name}</strong>").html_safe % { project_name: sanitize(@project.name, tags: []) } %span.badge.badge-pill= group_links.size %ul.content-list.members-list - = render partial: 'shared/members/group', collection: group_links, as: :group_link + - can_admin_member = can?(current_user, :admin_project_member, @project) + - @group_links.each do |group_link| + = render 'shared/members/group', group_link: group_link, can_admin_member: can_admin_member, group_link_path: project_group_link_path(@project, group_link) diff --git a/app/views/projects/project_members/_team.html.haml b/app/views/projects/project_members/_team.html.haml index 5310c1fad014ab7fda70436733766755a6bd611c..5d8005b2e2a1b15dc3268ebb6d5e9a26ab639506 100644 --- a/app/views/projects/project_members/_team.html.haml +++ b/app/views/projects/project_members/_team.html.haml @@ -13,5 +13,5 @@ %button.user-search-btn{ type: "submit", "aria-label" => _("Submit search") } = icon("search") = render 'shared/members/sort_dropdown' - %ul.content-list.members-list.qa-members-list + %ul.content-list.members-list{ data: { qa_selector: 'members_list' } } = render partial: 'shared/members/member', collection: members, as: :member diff --git a/app/views/projects/project_members/index.html.haml b/app/views/projects/project_members/index.html.haml index 24fe583a9b55e3f63d57a2d9e3392fd810b9af02..c24a9061146675476dfde35c79d60ef075c32570 100644 --- a/app/views/projects/project_members/index.html.haml +++ b/app/views/projects/project_members/index.html.haml @@ -23,13 +23,13 @@ .tab-content.gitlab-tab-content .tab-pane.active{ id: 'invite-member-pane', role: 'tabpanel' } - = render 'projects/project_members/new_project_member', tab_title: _('Invite member') + = render 'shared/members/invite_member', submit_url: project_project_members_path(@project), access_levels: ProjectMember.access_level_roles, default_access_level: @project_member.access_level, can_import_members?: can_import_members?, import_path: import_project_project_members_path(@project) .tab-pane{ id: 'invite-group-pane', role: 'tabpanel', class: ('active' if membership_locked?) } - = render 'projects/project_members/new_project_group', tab_title: _('Invite group') + = render 'shared/members/invite_group', submit_url: project_group_links_path(@project), access_levels: ProjectGroupLink.access_options, default_access_level: ProjectGroupLink.default_access, group_link_field: 'link_group_id', group_access_field: 'link_group_access' - elsif !membership_locked? - .invite-member= render 'projects/project_members/new_project_member', tab_title: _('Invite member') + .invite-member= render 'shared/members/invite_member', submit_url: project_project_members_path(@project), access_levels: ProjectMember.access_level_roles, default_access_level: @project_member.access_level, can_import_members?: can_import_members?, import_path: import_project_project_members_path(@project) - elsif @project.allowed_to_share_with_group? - .invite-group= render 'projects/project_members/new_project_group', tab_title: _('Invite group') + .invite-group= render 'shared/members/invite_group', access_levels: ProjectGroupLink.access_options, default_access_level: ProjectGroupLink.default_access, submit_url: project_group_links_path(@project), group_link_field: 'link_group_id', group_access_field: 'link_group_access' = render 'shared/members/requests', membership_source: @project, requesters: @requesters .clearfix diff --git a/app/views/projects/registry/settings/_index.haml b/app/views/projects/registry/settings/_index.haml index e1eed93664e4197dfc784c8ae9767d924b304b2a..0e0341a99239f268e5435bbf3a6a494ff05bfe33 100644 --- a/app/views/projects/registry/settings/_index.haml +++ b/app/views/projects/registry/settings/_index.haml @@ -1,2 +1,4 @@ -#js-registry-settings{ data: { registry_settings_endpoint: '', - help_page_path: help_page_path('user/project/operations/linking_to_an_external_dashboard') } } +#js-registry-settings{ data: { project_id: @project.id, + cadence_options: cadence_options.to_json, + keep_n_options: keep_n_options.to_json, + older_than_options: older_than_options.to_json} } diff --git a/app/views/projects/settings/ci_cd/show.html.haml b/app/views/projects/settings/ci_cd/show.html.haml index 38483f599b721a543c9cdc647b58e2b5ff0822f2..a65afeecc1704c6ddede83eba6a93a47a7848544 100644 --- a/app/views/projects/settings/ci_cd/show.html.haml +++ b/app/views/projects/settings/ci_cd/show.html.haml @@ -23,8 +23,11 @@ %button.btn.btn-default.js-settings-toggle{ type: 'button' } = expanded ? _('Collapse') : _('Expand') %p - = s_('CICD|Auto DevOps will automatically build, test, and deploy your application based on a predefined Continuous Integration and Delivery configuration.') - = link_to s_('CICD|Learn more about Auto DevOps'), help_page_path('topics/autodevops/index.md') + - auto_devops_url = help_page_path('topics/autodevops/index') + - quickstart_url = help_page_path('topics/autodevops/quick_start_guide') + - auto_devops_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: auto_devops_url } + - quickstart_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: quickstart_url } + = s_('AutoDevOps|Auto DevOps can automatically build, test, and deploy applications based on predefined continuous integration and delivery configuration. %{auto_devops_start}Learn more about Auto DevOps%{auto_devops_end} or use our %{quickstart_start}quick start guide%{quickstart_end} to get started right away.').html_safe % { auto_devops_start: auto_devops_start, auto_devops_end: '</a>'.html_safe, quickstart_start: quickstart_start, quickstart_end: '</a>'.html_safe } .settings-content = render 'autodevops_form', auto_devops_enabled: @project.auto_devops_enabled? @@ -60,13 +63,14 @@ = render 'projects/triggers/index' - if Feature.enabled?(:registry_retention_policies_settings, @project) - %section.settings.no-animate#js-registry-polcies{ class: ('expanded' if expanded) } + %section.settings.no-animate#js-registry-policies{ class: ('expanded' if expanded) } .settings-header %h4 - = _("Container Registry tag expiration policies") + = _("Container Registry tag expiration policy") + = link_to icon('question-circle'), help_page_path('user/packages/container_registry/index', anchor: 'retention-and-expiration-policy'), target: '_blank', rel: 'noopener noreferrer' %button.btn.js-settings-toggle{ type: 'button' } = expanded ? _('Collapse') : _('Expand') %p - = _("Expiration policies for the Container Registry are a perfect solution for keeping the Registry space down while still enjoying the full power of GitLab CI/CD.") + = _("Expiration policy for the Container Registry is a perfect solution for keeping the Registry space down while still enjoying the full power of GitLab CI/CD.") .settings-content = render 'projects/registry/settings/index' diff --git a/app/views/projects/settings/operations/_error_tracking.html.haml b/app/views/projects/settings/operations/_error_tracking.html.haml index 589d3037eba4218e97037081ba6346a0d7139c38..06b5243dfd944008ff4d158ac85afadd679e1eac 100644 --- a/app/views/projects/settings/operations/_error_tracking.html.haml +++ b/app/views/projects/settings/operations/_error_tracking.html.haml @@ -12,7 +12,7 @@ = _('To link Sentry to GitLab, enter your Sentry URL and Auth Token.') = link_to _('More information'), help_page_path('user/project/operations/error_tracking'), target: '_blank', rel: 'noopener noreferrer' .settings-content - .js-error-tracking-form{ data: { list_projects_endpoint: list_projects_project_error_tracking_index_path(@project, format: :json), + .js-error-tracking-form{ data: { list_projects_endpoint: project_error_tracking_projects_path(@project, format: :json), operations_settings_endpoint: project_settings_operations_path(@project), project: error_tracking_setting_project_json, api_host: setting.api_host, diff --git a/app/views/projects/triggers/_content.html.haml b/app/views/projects/triggers/_content.html.haml deleted file mode 100644 index e686068657c3d9a0c115061101f468750f30c4c3..0000000000000000000000000000000000000000 --- a/app/views/projects/triggers/_content.html.haml +++ /dev/null @@ -1,9 +0,0 @@ -- if Feature.enabled?(:use_legacy_pipeline_triggers, @project) - %p.append-bottom-default - Triggers with the - %span.badge.badge-primary legacy - label do not have an associated user and only have access to the current project. - %br - = succeed '.' do - Learn more in the - = link_to 'triggers documentation', help_page_path('ci/triggers/README'), target: '_blank' diff --git a/app/views/projects/triggers/_index.html.haml b/app/views/projects/triggers/_index.html.haml index a559ce41e57287b1f98c20b7bd4a4bc635c6b1b1..55a9234f01a3dc352a740fd3d2d57222cd43eae4 100644 --- a/app/views/projects/triggers/_index.html.haml +++ b/app/views/projects/triggers/_index.html.haml @@ -1,6 +1,5 @@ .row.prepend-top-default.append-bottom-default.triggers-container .col-lg-12 - = render "projects/triggers/content" .card .card-header Manage your project's triggers diff --git a/app/views/projects/triggers/_trigger.html.haml b/app/views/projects/triggers/_trigger.html.haml index 60de3630bb5932df7e9cd488148c1dc07e190765..d80248f7e804b817f5084ca420968c62f3f4b23f 100644 --- a/app/views/projects/triggers/_trigger.html.haml +++ b/app/views/projects/triggers/_trigger.html.haml @@ -7,12 +7,7 @@ %span= trigger.short_token .label-container - - if trigger.legacy? - - if trigger.supports_legacy_tokens? - %span.badge.badge-primary.has-tooltip{ title: "Trigger makes use of deprecated functionality" } legacy - - else - %span.badge.badge-danger.has-tooltip{ title: "Trigger is invalid due to being a legacy trigger. We recommend replacing it with a new trigger" } invalid - - elsif !trigger.can_access_project? + - unless trigger.can_access_project? %span.badge.badge-danger.has-tooltip{ title: "Trigger user has insufficient permissions to project" } invalid %td diff --git a/app/views/projects/triggers/edit.html.haml b/app/views/projects/triggers/edit.html.haml index c35df322b9dfd628a68d7a8614a27e948b845210..0f74d733c067e7fb074729d24fa5ddc495a1c847 100644 --- a/app/views/projects/triggers/edit.html.haml +++ b/app/views/projects/triggers/edit.html.haml @@ -1,9 +1,7 @@ - page_title "Trigger" .row.prepend-top-default.append-bottom-default - .col-lg-3 - = render "content" - .col-lg-9 + .col-lg-12 %h4.prepend-top-0 Update trigger = render "form", btn_text: "Save trigger" diff --git a/app/views/registrations/welcome.html.haml b/app/views/registrations/welcome.html.haml index 7b92f5070df0a2697cfe888d6c4a3284988f1355..bc8d7ed10efb135c651c6a2b4bef296914d242d4 100644 --- a/app/views/registrations/welcome.html.haml +++ b/app/views/registrations/welcome.html.haml @@ -1,5 +1,4 @@ -- content_for(:page_title, _('Welcome to GitLab @%{username}!') % { username: current_user.username }) -- max_name_length = 128 +- content_for(:page_title, _('Welcome to GitLab %{name}!') % { name: current_user.name }) .text-center.mb-3 = _('In order to tailor your experience with GitLab we<br>would like to know a bit more about you.').html_safe .signup-box.p-3.mb-2 @@ -7,9 +6,6 @@ = form_for(current_user, url: users_sign_up_update_registration_path, html: { class: 'new_new_user gl-show-field-errors', 'aria-live' => 'assertive' }) do |f| .devise-errors.mt-0 = render 'devise/shared/error_messages', resource: current_user - .name.form-group - = f.label :name, _('Full name'), class: 'label-bold' - = f.text_field :name, class: 'form-control top js-block-emoji js-validate-length', :data => { :max_length => max_name_length, :max_length_message => s_('Name is too long (maximum is %{max_length} characters).') % { max_length: max_name_length }, :qa_selector => 'new_user_name_field' }, required: true, title: _('This field is required.') .form-group = f.label :role, _('Role'), class: 'label-bold' = f.select :role, ::User.roles.keys.map { |role| [role.titleize, role] }, {}, class: 'form-control' diff --git a/app/views/shared/_auto_devops_callout.html.haml b/app/views/shared/_auto_devops_callout.html.haml index 084d295f2c1888cce19cea73412c1b709d9b3b2a..128508e954efdf235817e82b333a59acd999020c 100644 --- a/app/views/shared/_auto_devops_callout.html.haml +++ b/app/views/shared/_auto_devops_callout.html.haml @@ -1,16 +1,15 @@ -.js-autodevops-banner.banner-callout.banner-non-empty-state.append-bottom-20.prepend-top-10{ data: { uid: 'auto_devops_settings_dismissed', project_path: project_path(@project) } } - .banner-graphic - = custom_icon('icon_autodevops') +%section.js-autodevops-banner.gl-banner{ data: { uid: 'auto_devops_settings_dismissed', project_path: project_path(@project) } } + .gl-banner-illustration + = image_tag('illustrations/autodevops.svg') - .banner-body.prepend-left-10.append-bottom-10 - %h5.banner-title= s_('AutoDevOps|Auto DevOps') + .gl-banner-content + %h1.gl-banner-title= s_('AutoDevOps|Auto DevOps') %p= s_('AutoDevOps|It will automatically build, test, and deploy your application based on a predefined CI/CD configuration.') %p - link = link_to(s_('AutoDevOps|Auto DevOps documentation'), help_page_path('topics/autodevops/index.md'), target: '_blank', rel: 'noopener noreferrer') = s_('AutoDevOps|Learn more in the %{link_to_documentation}').html_safe % { link_to_documentation: link } - .banner-buttons - = link_to s_('AutoDevOps|Enable in settings'), project_settings_ci_cd_path(@project, anchor: 'autodevops-settings'), class: 'btn js-close-callout' + = link_to s_('AutoDevOps|Enable in settings'), project_settings_ci_cd_path(@project, anchor: 'autodevops-settings'), class: 'btn btn-md new-gl-button js-close-callout' - %button.btn-transparent.banner-close.close.js-close-callout{ type: 'button', + %button.gl-banner-close.close.js-close-callout{ type: 'button', 'aria-label' => 'Dismiss Auto DevOps box' } = icon('times', class: 'dismiss-icon', 'aria-hidden' => 'true') diff --git a/app/views/shared/boards/components/_board.html.haml b/app/views/shared/boards/components/_board.html.haml index eb9b7f6c48aa6b6b4b1eb680cea5551697baf3aa..a62c385d711b54fe0afacb50571b8a15cf099396 100644 --- a/app/views/shared/boards/components/_board.html.haml +++ b/app/views/shared/boards/components/_board.html.haml @@ -42,9 +42,10 @@ %button.board-delete.no-drag.p-0.border-0.has-tooltip.float-right{ type: "button", title: _("Delete list"), ":class": "{ 'd-none': !list.isExpanded }", "aria-label" => _("Delete list"), data: { placement: "bottom" }, "@click.stop" => "deleteBoard" } = icon("trash") - .issue-count-badge.pr-0.no-drag.text-secondary{ "v-if" => "showBoardListAndBoardInfo", ":title": "counterTooltip", "v-tooltip": true, data: { placement: "top" } } + .issue-count-badge.pr-0.no-drag.text-secondary{ "v-if" => "showBoardListAndBoardInfo" } %span.d-inline-flex - %span.issue-count-badge-count + %gl-tooltip{ ":target" => "() => $refs.issueCount", ":title" => "issuesTooltip" } + %span.issue-count-badge-count{ "ref" => "issueCount" } %icon.mr-1{ name: "issues" } %issue-count{ ":maxIssueCount" => "list.maxIssueCount", ":issuesSize" => "list.issuesSize" } diff --git a/app/views/shared/file_hooks/_index.html.haml b/app/views/shared/file_hooks/_index.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..74eb6c941160013314019d8edd29f69671c7c5d7 --- /dev/null +++ b/app/views/shared/file_hooks/_index.html.haml @@ -0,0 +1,24 @@ +- file_hooks = Gitlab::FileHook.files + +.row.prepend-top-default + .col-lg-4 + %h4.prepend-top-0 + = _('File Hooks') + %p + = _('File hooks are similar to system hooks but are executed as files instead of sending data to a URL.') + = link_to _('For more information, see the File Hooks documentation.'), help_page_path('administration/file_hooks') + + + .col-lg-8.append-bottom-default + - if file_hooks.any? + .card + .card-header + = _('File Hooks (%{count})') % { count: file_hooks.count } + %ul.content-list + - file_hooks.each do |file| + %li + .monospace + = File.basename(file) + - else + .card.bg-light.text-center + .nothing-here-block= _('No file hooks found.') diff --git a/app/views/shared/form_elements/_description.html.haml b/app/views/shared/form_elements/_description.html.haml index 9db6184ebca6d301c66798e30c3eea5eb9735a8d..2f2e6d83f9f0a204f0912a98841a64fc446afafd 100644 --- a/app/views/shared/form_elements/_description.html.haml +++ b/app/views/shared/form_elements/_description.html.haml @@ -1,6 +1,8 @@ - project = local_assigns.fetch(:project) - model = local_assigns.fetch(:model) + + - form = local_assigns.fetch(:form) - placeholder = model.is_a?(MergeRequest) ? _('Describe the goal of the changes and what reviewers should be aware of.') : _('Write a comment or drag your files here…') - supports_quick_actions = model.new_record? @@ -14,6 +16,8 @@ = form.label :description, 'Description', class: 'col-form-label col-sm-2' .col-sm-10 + - if model.is_a?(Issuable) + = render 'shared/issuable/form/template_selector', issuable: model = render layout: 'projects/md_preview', locals: { url: preview_url, referenced_users: true } do = render 'projects/zen', f: form, attr: :description, classes: 'note-textarea qa-issuable-form-description rspec-issuable-form-description', diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml index 0fb23adc31f7b14929604aea7d65386d7b8e57af..a020a04e366fb72416b48a69a0d220cb19ac08f5 100644 --- a/app/views/shared/issuable/_form.html.haml +++ b/app/views/shared/issuable/_form.html.haml @@ -17,7 +17,6 @@ .form-group.row = form.label :title, class: 'col-form-label col-sm-2' - = render 'shared/issuable/form/template_selector', issuable: issuable = render 'shared/issuable/form/title', issuable: issuable, form: form, has_wip_commits: commits && commits.detect(&:work_in_progress?) #js-suggestions{ data: { project_path: @project.full_path } } diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml index 5da86195243431fd133bebdb48c3274ebcf963d3..c3960ec5026a51f96696a83b7e5bedb6bbcf036b 100644 --- a/app/views/shared/issuable/_search_bar.html.haml +++ b/app/views/shared/issuable/_search_bar.html.haml @@ -1,5 +1,6 @@ - type = local_assigns.fetch(:type) - board = local_assigns.fetch(:board, nil) +- show_sorting_dropdown = local_assigns.fetch(:show_sorting_dropdown, true) - is_not_boards_modal_or_productivity_analytics = type != :boards_modal && type != :productivity_analytics - block_css_class = is_not_boards_modal_or_productivity_analytics ? 'row-content-block second-block' : '' - user_can_admin_list = board && can?(current_user, :admin_list, board.resource_parent) @@ -30,23 +31,22 @@ %li.input-token %input.form-control.filtered-search{ search_filter_input_options(type) } #js-dropdown-hint.filtered-search-input-dropdown-menu.dropdown-menu.hint-dropdown - %ul{ data: { dropdown: true } } - %li.filter-dropdown-item{ data: { action: 'submit' } } - %button.btn.btn-link{ type: 'button' } - = sprite_icon('search') - %span - = _('Press Enter or click to search') %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } - %li.filter-dropdown-item + %li.filter-dropdown-item{ data: {hint: "#{'{{hint}}'}", tag: "#{'{{tag}}'}", action: "#{'{{hint === \'search\' ? \'submit\' : \'\' }}'}" } } %button.btn.btn-link{ type: 'button' } -# Encapsulate static class name `{{icon}}` inside #{} to bypass -# haml lint's ClassAttributeWithStaticValue %svg %use{ 'xlink:href': "#{'{{icon}}'}" } %span.js-filter-hint - {{hint}} - %span.js-filter-tag.dropdown-light-content - {{tag}} + {{formattedKey}} + #js-dropdown-operator.filtered-search-input-dropdown-menu.dropdown-menu + %ul.filter-dropdown{ data: { dropdown: true, dynamic: true } } + %li.filter-dropdown-item{ data: { value: "{{ title }}" } } + %button.btn.btn-link{ type: 'button' } + {{ title }} + %span.btn-helptext + {{ help }} #js-dropdown-author.filtered-search-input-dropdown-menu.dropdown-menu - if current_user %ul{ data: { dropdown: true } } @@ -170,5 +170,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 is_not_boards_modal_or_productivity_analytics + - elsif is_not_boards_modal_or_productivity_analytics && show_sorting_dropdown = render 'shared/issuable/sort_dropdown' diff --git a/app/views/shared/issuable/form/_template_selector.html.haml b/app/views/shared/issuable/form/_template_selector.html.haml index d613bd31d81f19db2c657c4219b3f48aff4f21bf..bf34ea4a1b2edc0db82f11e4d9d4ee82a9dad75c 100644 --- a/app/views/shared/issuable/form/_template_selector.html.haml +++ b/app/views/shared/issuable/form/_template_selector.html.haml @@ -2,7 +2,7 @@ - return unless issuable && issuable_templates(issuable).any? -.col-sm-3.col-lg-2 +.issuable-form-select-holder.selectbox.form-group .js-issuable-selector-wrap{ data: { issuable_type: issuable.to_ability_name } } = template_dropdown_tag(issuable) do %ul.dropdown-footer-list diff --git a/app/views/shared/members/_group.html.haml b/app/views/shared/members/_group.html.haml index 18368ecc9ff70bcdc8d70ed73d02e89a8434afb4..4aeeac87f3cb57bd2c5e59f71905777c64af49ca 100644 --- a/app/views/shared/members/_group.html.haml +++ b/app/views/shared/members/_group.html.haml @@ -1,6 +1,7 @@ - group_link = local_assigns[:group_link] -- group = group_link.group -- can_admin_member = can?(current_user, :admin_project_member, @project) +- group = group_link.shared_with_group +- can_admin_member = local_assigns[:can_admin_member] +- group_link_path = local_assigns[:group_link_path] - dom_id = "group_member_#{group_link.id}" -# Note this is just for groups. For individual members please see shared/members/_member @@ -17,7 +18,7 @@ %span{ class: ('text-warning' if group_link.expires_soon?) } = _("Expires in %{expires_at}").html_safe % { expires_at: distance_of_time_in_words_to_now(group_link.expires_at) } .controls.member-controls.align-items-center - = form_tag project_group_link_path(@project, group_link), method: :put, remote: true, class: 'js-edit-member-form form-group d-sm-flex' do + = form_tag group_link_path, method: :put, remote: true, class: 'js-edit-member-form form-group d-sm-flex' do = hidden_field_tag "group_link[group_access]", group_link.group_access .member-form-control.dropdown.mr-sm-2.d-sm-inline-block %button.dropdown-menu-toggle.js-member-permissions-dropdown{ type: "button", @@ -39,7 +40,7 @@ = text_field_tag 'group_link[expires_at]', group_link.expires_at, class: 'form-control js-access-expiration-date js-member-update-control', placeholder: _('Expiration date'), id: "member_expires_at_#{group.id}", disabled: !can_admin_member %i.clear-icon.js-clear-input - if can_admin_member - = link_to project_group_link_path(@project, group_link), + = link_to group_link_path, method: :delete, data: { confirm: _("Are you sure you want to remove %{group_name}?") % { group_name: group.name }, qa_selector: 'delete_group_access_link' }, class: 'btn btn-remove m-0 ml-sm-2 align-self-center' do diff --git a/app/views/projects/project_members/_new_project_group.html.haml b/app/views/shared/members/_invite_group.html.haml similarity index 52% rename from app/views/projects/project_members/_new_project_group.html.haml rename to app/views/shared/members/_invite_group.html.haml index d413048ca10b02f877da23ab7d6482538c9391dc..27c930bcbb59efb174db90b3b9f2f7955aea5720 100644 --- a/app/views/projects/project_members/_new_project_group.html.haml +++ b/app/views/shared/members/_invite_group.html.haml @@ -1,13 +1,18 @@ +- access_levels = local_assigns[:access_levels] +- default_access_level = local_assigns[:default_access_level] +- submit_url = local_assigns[:submit_url] +- group_link_field = local_assigns[:group_link_field] +- group_access_field = local_assigns[:group_access_field] .row .col-sm-12 - = form_tag project_group_links_path(@project), class: 'js-requires-input', method: :post do + = form_tag submit_url, class: 'invite-group-form js-requires-input', method: :post do .form-group - = label_tag :link_group_id, _("Select a group to invite"), class: "label-bold" - = groups_select_tag(:link_group_id, data: { skip_groups: @skip_groups }, class: "input-clamp qa-group-select-field", required: true) + = label_tag group_link_field, _("Select a group to invite"), class: "label-bold" + = groups_select_tag(group_link_field, data: { skip_groups: @skip_groups }, class: 'input-clamp qa-group-select-field', required: true) .form-group - = label_tag :link_group_access, _("Max access level"), class: "label-bold" + = label_tag group_access_field, _("Max access level"), class: "label-bold" .select-wrapper - = select_tag :link_group_access, options_for_select(ProjectGroupLink.access_options, ProjectGroupLink.default_access), class: "form-control select-control" + = select_tag group_access_field, options_for_select(access_levels, default_access_level), data: { qa_selector: 'group_access_field' }, class: "form-control select-control" = icon('chevron-down') .form-text.text-muted.append-bottom-10 - permissions_docs_path = help_page_path('user/permissions') diff --git a/app/views/projects/project_members/_new_project_member.html.haml b/app/views/shared/members/_invite_member.html.haml similarity index 50% rename from app/views/projects/project_members/_new_project_member.html.haml rename to app/views/shared/members/_invite_member.html.haml index 149b0d6cddd7d0c133042830797210bc859785c7..d3a1c85e285550405b9c423579f06f5099be612f 100644 --- a/app/views/projects/project_members/_new_project_member.html.haml +++ b/app/views/shared/members/_invite_member.html.haml @@ -1,13 +1,18 @@ +- access_levels = local_assigns[:access_levels] +- default_access_level = local_assigns[:default_access_level] +- submit_url = local_assigns[:submit_url] +- can_import_members = local_assigns[:can_import_members?] +- import_path = local_assigns[:import_path] .row .col-sm-12 - = form_for @project_member, as: :project_member, url: project_project_members_path(@project), html: { class: 'users-project-form' } do |f| + = form_tag submit_url, class: 'invite-users-form', method: :post do .form-group = label_tag :user_ids, _("GitLab member or Email address"), class: "label-bold" - = users_select_tag(:user_ids, multiple: true, class: "input-clamp qa-member-select-input", scope: :all, email_user: true, placeholder: "Search for members to update or invite") + = users_select_tag(:user_ids, multiple: true, class: 'input-clamp qa-member-select-field', scope: :all, email_user: true, placeholder: 'Search for members to update or invite') .form-group = label_tag :access_level, _("Choose a role permission"), class: "label-bold" .select-wrapper - = select_tag :access_level, options_for_select(ProjectMember.access_level_roles, @project_member.access_level), class: "form-control project-access-select select-control" + = select_tag :access_level, options_for_select(access_levels, default_access_level), class: "form-control project-access-select select-control" = icon('chevron-down') .form-text.text-muted.append-bottom-10 - permissions_docs_path = help_page_path('user/permissions') @@ -18,6 +23,6 @@ = label_tag :expires_at, _('Access expiration date'), class: 'label-bold' = text_field_tag :expires_at, nil, class: 'form-control js-access-expiration-date', placeholder: 'Expiration date' %i.clear-icon.js-clear-input - = f.submit _("Add to project"), class: "btn btn-success qa-add-member-button" - - if can_import_members? - = link_to _("Import"), import_project_project_members_path(@project), class: "btn btn-default", title: _("Import members from another project") + = submit_tag _("Invite"), class: "btn btn-success", data: { qa_selector: 'invite_member_button' } + - if can_import_members + = link_to _("Import"), import_path, class: "btn btn-default", title: _("Import members from another project") diff --git a/app/views/shared/plugins/_index.html.haml b/app/views/shared/plugins/_index.html.haml deleted file mode 100644 index 9d230d12be2fbf76166a25b721b8d547e5df6288..0000000000000000000000000000000000000000 --- a/app/views/shared/plugins/_index.html.haml +++ /dev/null @@ -1,23 +0,0 @@ -- plugins = Gitlab::Plugin.files - -.row.prepend-top-default - .col-lg-4 - %h4.prepend-top-0 - Plugins - %p - #{link_to 'Plugins', help_page_path('administration/plugins')} are similar to - system hooks but are executed as files instead of sending data to a URL. - - .col-lg-8.append-bottom-default - - if plugins.any? - .card - .card-header - Plugins (#{plugins.count}) - %ul.content-list - - plugins.each do |file| - %li - .monospace - = File.basename(file) - - else - .card.bg-light.text-center - .nothing-here-block No plugins found. diff --git a/app/views/shared/projects/_list.html.haml b/app/views/shared/projects/_list.html.haml index fab7ee9d7636a89800f5ba05201a0a24c89fb58f..c0c009f2a860a1d050d76283db32ecd6ab9f964a 100644 --- a/app/views/shared/projects/_list.html.haml +++ b/app/views/shared/projects/_list.html.haml @@ -6,7 +6,6 @@ - merge_requests = true unless local_assigns[:merge_requests] == false - issues = true unless local_assigns[:issues] == false - pipeline_status = true unless local_assigns[:pipeline_status] == false -- ci = false unless local_assigns[:ci] == true - skip_namespace = false unless local_assigns[:skip_namespace] == true - user = local_assigns[:user] - show_last_commit_as_description = false unless local_assigns[:show_last_commit_as_description] == true @@ -40,7 +39,7 @@ - projects.each_with_index do |project, i| - css_class = (i >= projects_limit) || project.pending_delete? ? 'hide' : nil = render "shared/projects/project", project: project, skip_namespace: skip_namespace, - avatar: avatar, stars: stars, css_class: css_class, ci: ci, use_creator_avatar: use_creator_avatar, + avatar: avatar, stars: stars, css_class: css_class, use_creator_avatar: use_creator_avatar, forks: forks, show_last_commit_as_description: show_last_commit_as_description, user: user, merge_requests: merge_requests, issues: issues, pipeline_status: pipeline_status, compact_mode: compact_mode = paginate_collection(projects, remote: remote) unless skip_pagination diff --git a/app/views/shared/snippets/_embed.html.haml b/app/views/shared/snippets/_embed.html.haml index d2e35511b32558d629cd7e99e6c67336f5cc811d..b401820daf634cdc67da5a43c9090ba491a9b2e6 100644 --- a/app/views/shared/snippets/_embed.html.haml +++ b/app/views/shared/snippets/_embed.html.haml @@ -10,10 +10,8 @@ %small = number_to_human_size(blob.raw_size) - %a.gitlab-logo{ href: url_for(only_path: false, overwrite_params: nil), title: 'view on gitlab' } - on - %span.logo-text - GitLab + %a.gitlab-logo-wrapper{ href: url_for(only_path: false, overwrite_params: nil), title: 'view on gitlab' } + %img.gitlab-logo{ src: image_url('ext_snippet_icons/logo.svg'), alt: "GitLab logo" } .file-actions.d-none.d-sm-block .btn-group{ role: "group" }< diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index 02acf360afc81af2ba03ea05c0e8cd1b17951086..62b37f52cce16073e2c2aa6c2de5fd0bd37a0cdb 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -10,6 +10,7 @@ - chaos:chaos_sleep - cronjob:admin_email +- cronjob:container_expiration_policy - cronjob:expire_build_artifacts - cronjob:gitlab_usage_ping - cronjob:import_export_project_cleanup @@ -103,6 +104,7 @@ - pipeline_processing:stage_update - pipeline_processing:update_head_pipeline_for_merge_request - pipeline_processing:ci_build_schedule +- pipeline_processing:ci_resource_groups_assign_resource_from_resource_group - deployment:deployments_success - deployment:deployments_finished @@ -156,7 +158,7 @@ - pages - pages_domain_verification - pages_domain_ssl_renewal -- plugin +- file_hook - post_receive - process_commit - project_cache @@ -186,3 +188,5 @@ - project_daily_statistics - create_evidence - group_export +- self_monitoring_project_create +- self_monitoring_project_delete diff --git a/app/workers/chat_notification_worker.rb b/app/workers/chat_notification_worker.rb index 42a23cd472a9a06efcdd9a75f7aa351f489d47da..6162dcf9d386f15306ae0b67f2de4662c2083a31 100644 --- a/app/workers/chat_notification_worker.rb +++ b/app/workers/chat_notification_worker.rb @@ -3,6 +3,9 @@ class ChatNotificationWorker include ApplicationWorker + TimeoutExceeded = Class.new(StandardError) + + sidekiq_options retry: false feature_category :chatops latency_sensitive_worker! # TODO: break this into multiple jobs @@ -11,18 +14,21 @@ class ChatNotificationWorker # worker_has_external_dependencies! RESCHEDULE_INTERVAL = 2.seconds + RESCHEDULE_TIMEOUT = 5.minutes # rubocop: disable CodeReuse/ActiveRecord - def perform(build_id) + def perform(build_id, reschedule_count = 0) Ci::Build.find_by(id: build_id).try do |build| send_response(build) end rescue Gitlab::Chat::Output::MissingBuildSectionError + raise TimeoutExceeded if timeout_exceeded?(reschedule_count) + # The creation of traces and sections appears to be eventually consistent. # As a result it's possible for us to run the above code before the trace # sections are present. To better handle such cases we'll just reschedule # the job instead of producing an error. - self.class.perform_in(RESCHEDULE_INTERVAL, build_id) + self.class.perform_in(RESCHEDULE_INTERVAL, build_id, reschedule_count + 1) end # rubocop: enable CodeReuse/ActiveRecord @@ -37,4 +43,10 @@ class ChatNotificationWorker end end end + + private + + def timeout_exceeded?(reschedule_count) + (reschedule_count * RESCHEDULE_INTERVAL) >= RESCHEDULE_TIMEOUT + end end diff --git a/app/workers/ci/resource_groups/assign_resource_from_resource_group_worker.rb b/app/workers/ci/resource_groups/assign_resource_from_resource_group_worker.rb new file mode 100644 index 0000000000000000000000000000000000000000..62233d19516996b80e7395d448b0c9f62a709731 --- /dev/null +++ b/app/workers/ci/resource_groups/assign_resource_from_resource_group_worker.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Ci + module ResourceGroups + class AssignResourceFromResourceGroupWorker + include ApplicationWorker + include PipelineQueue + + queue_namespace :pipeline_processing + feature_category :continuous_delivery + + def perform(resource_group_id) + ::Ci::ResourceGroup.find_by_id(resource_group_id).try do |resource_group| + Ci::ResourceGroups::AssignResourceFromResourceGroupService.new(resource_group.project, nil) + .execute(resource_group) + end + end + end + end +end diff --git a/app/workers/concerns/cluster_queue.rb b/app/workers/concerns/cluster_queue.rb index 180b86b0124e5638c377cb857a084caa018fcf95..60ba878534763424ed77d43a04a336f52472cfd4 100644 --- a/app/workers/concerns/cluster_queue.rb +++ b/app/workers/concerns/cluster_queue.rb @@ -8,6 +8,6 @@ module ClusterQueue included do queue_namespace :gcp_cluster - feature_category :kubernetes_configuration + feature_category :kubernetes_management end end diff --git a/app/workers/concerns/reenqueuer.rb b/app/workers/concerns/reenqueuer.rb new file mode 100644 index 0000000000000000000000000000000000000000..5cc13e490d87e552ee10303df534f5460527740e --- /dev/null +++ b/app/workers/concerns/reenqueuer.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +# +# A concern that helps run exactly one instance of a worker, over and over, +# until it returns false or raises. +# +# To ensure the worker is always up, you can schedule it every minute with +# sidekiq-cron. Excess jobs will immediately exit due to an exclusive lease. +# +# The worker must define: +# +# - `#perform` +# - `#lease_timeout` +# +# The worker spec should include `it_behaves_like 'reenqueuer'` and +# `it_behaves_like 'it is rate limited to 1 call per'`. +# +# Optionally override `#minimum_duration` to adjust the rate limit. +# +# When `#perform` returns false, the job will not be reenqueued. Instead, we +# will wait for the next one scheduled by sidekiq-cron. +# +# #lease_timeout should be longer than the longest possible `#perform`. +# The lease is normally released in an ensure block, but it is possible to +# orphan the lease by killing Sidekiq, so it should also be as short as +# possible. Consider that long-running jobs are generally not recommended. +# Ideally, every job finishes within 25 seconds because that is the default +# wait time for graceful termination. +# +# Timing: It runs as often as Sidekiq allows. We rate limit with sleep for +# now: https://gitlab.com/gitlab-org/gitlab/issues/121697 +module Reenqueuer + extend ActiveSupport::Concern + + prepended do + include ExclusiveLeaseGuard + include ReenqueuerSleeper + + sidekiq_options retry: false + end + + def perform(*args) + try_obtain_lease do + reenqueue(*args) do + ensure_minimum_duration(minimum_duration) do + super + end + end + end + end + + private + + def reenqueue(*args) + self.class.perform_async(*args) if yield + end + + # Override as needed + def minimum_duration + 5.seconds + end + + # We intend to get rid of sleep: + # https://gitlab.com/gitlab-org/gitlab/issues/121697 + module ReenqueuerSleeper + # The block will run, and then sleep until the minimum duration. Returns the + # block's return value. + # + # Usage: + # + # ensure_minimum_duration(5.seconds) do + # # do something + # end + # + def ensure_minimum_duration(minimum_duration) + start_time = Time.now + + result = yield + + sleep_if_time_left(minimum_duration, start_time) + + result + end + + private + + def sleep_if_time_left(minimum_duration, start_time) + time_left = calculate_time_left(minimum_duration, start_time) + + sleep(time_left) if time_left > 0 + end + + def calculate_time_left(minimum_duration, start_time) + minimum_duration - elapsed_time(start_time) + end + + def elapsed_time(start_time) + Time.now - start_time + end + end +end diff --git a/app/workers/concerns/self_monitoring_project_worker.rb b/app/workers/concerns/self_monitoring_project_worker.rb new file mode 100644 index 0000000000000000000000000000000000000000..44dd6866fad7c5f2388a667296857a24c385e665 --- /dev/null +++ b/app/workers/concerns/self_monitoring_project_worker.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module SelfMonitoringProjectWorker + extend ActiveSupport::Concern + + included do + # This worker falls under Self-monitoring with Monitor::APM group. However, + # self-monitoring is not classified as a feature category but rather as + # Other Functionality. Metrics seems to be the closest feature_category for + # this worker. + feature_category :metrics + end + + LEASE_TIMEOUT = 15.minutes.to_i + EXCLUSIVE_LEASE_KEY = 'self_monitoring_service_creation_deletion' + + class_methods do + # @param job_id [String] + # Job ID that is used to construct the cache keys. + # @return [Hash] + # Returns true if the job is enqueued or in progress and false otherwise. + def in_progress?(job_id) + Gitlab::SidekiqStatus.job_status(Array.wrap(job_id)).first + end + end + + private + + def lease_key + EXCLUSIVE_LEASE_KEY + end + + def lease_timeout + self.class::LEASE_TIMEOUT + end +end diff --git a/app/models/concerns/worker_attributes.rb b/app/workers/concerns/worker_attributes.rb similarity index 100% rename from app/models/concerns/worker_attributes.rb rename to app/workers/concerns/worker_attributes.rb diff --git a/app/workers/container_expiration_policy_worker.rb b/app/workers/container_expiration_policy_worker.rb new file mode 100644 index 0000000000000000000000000000000000000000..595208230f60e7a1f608987d6a460786c75d45c0 --- /dev/null +++ b/app/workers/container_expiration_policy_worker.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class ContainerExpirationPolicyWorker + include ApplicationWorker + include CronjobQueue + + feature_category :container_registry + + def perform + ContainerExpirationPolicy.runnable_schedules.preloaded.find_each do |container_expiration_policy| + ContainerExpirationPolicyService.new( + container_expiration_policy.project, container_expiration_policy.project.owner + ).execute(container_expiration_policy) + end + end +end diff --git a/app/workers/deployments/finished_worker.rb b/app/workers/deployments/finished_worker.rb index 90bbc193651736b11246faa8fec8026b8a44f50a..6196b032f63055837ce39fbd5e67f393c2c7d5b5 100644 --- a/app/workers/deployments/finished_worker.rb +++ b/app/workers/deployments/finished_worker.rb @@ -9,7 +9,10 @@ module Deployments worker_resource_boundary :cpu def perform(deployment_id) - Deployment.find_by_id(deployment_id).try(:execute_hooks) + if (deploy = Deployment.find_by_id(deployment_id)) + LinkMergeRequestsService.new(deploy).execute + deploy.execute_hooks + end end end end diff --git a/app/workers/plugin_worker.rb b/app/workers/file_hook_worker.rb similarity index 54% rename from app/workers/plugin_worker.rb rename to app/workers/file_hook_worker.rb index e708031abdfe2896a1cb7a5cf1d194a25f2175f5..24fc2d75d24f6daf244093cc2b62c1aee1bf74ec 100644 --- a/app/workers/plugin_worker.rb +++ b/app/workers/file_hook_worker.rb @@ -1,16 +1,16 @@ # frozen_string_literal: true -class PluginWorker +class FileHookWorker include ApplicationWorker sidekiq_options retry: false feature_category :integrations def perform(file_name, data) - success, message = Gitlab::Plugin.execute(file_name, data) + success, message = Gitlab::FileHook.execute(file_name, data) unless success - Gitlab::PluginLogger.error("Plugin Error => #{file_name}: #{message}") + Gitlab::FileHookLogger.error("File Hook Error => #{file_name}: #{message}") end true diff --git a/app/workers/group_destroy_worker.rb b/app/workers/group_destroy_worker.rb index 553fd359bafda9c68e829dab3333a30388adcb8d..fc751f8b61213a9e52c55eca9bed336d03cfe292 100644 --- a/app/workers/group_destroy_worker.rb +++ b/app/workers/group_destroy_worker.rb @@ -4,7 +4,7 @@ class GroupDestroyWorker include ApplicationWorker include ExceptionBacktrace - feature_category :groups + feature_category :subgroups def perform(group_id, user_id) begin diff --git a/app/workers/pipeline_update_worker.rb b/app/workers/pipeline_update_worker.rb index 5b742461f7a0d11746550733aad321498e6d59d1..0321ea5a6ce55a75609c80f526043af06074a61d 100644 --- a/app/workers/pipeline_update_worker.rb +++ b/app/workers/pipeline_update_worker.rb @@ -7,10 +7,7 @@ class PipelineUpdateWorker queue_namespace :pipeline_processing latency_sensitive_worker! - # rubocop: disable CodeReuse/ActiveRecord def perform(pipeline_id) - Ci::Pipeline.find_by(id: pipeline_id) - .try(:update_status) + Ci::Pipeline.find_by_id(pipeline_id)&.update_legacy_status end - # rubocop: enable CodeReuse/ActiveRecord end diff --git a/app/workers/rebase_worker.rb b/app/workers/rebase_worker.rb index 7343226fdcd0947bbd6105a63c567c5384a996d1..fd182125c079eeee4719286649928e4d092c6b0a 100644 --- a/app/workers/rebase_worker.rb +++ b/app/workers/rebase_worker.rb @@ -7,12 +7,12 @@ class RebaseWorker feature_category :source_code_management - def perform(merge_request_id, current_user_id) + def perform(merge_request_id, current_user_id, skip_ci = false) current_user = User.find(current_user_id) merge_request = MergeRequest.find(merge_request_id) MergeRequests::RebaseService .new(merge_request.source_project, current_user) - .execute(merge_request) + .execute(merge_request, skip_ci: skip_ci) end end diff --git a/app/workers/self_monitoring_project_create_worker.rb b/app/workers/self_monitoring_project_create_worker.rb new file mode 100644 index 0000000000000000000000000000000000000000..429ac8aacc472e0e63e65b690057b5f5cd5eb3c8 --- /dev/null +++ b/app/workers/self_monitoring_project_create_worker.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class SelfMonitoringProjectCreateWorker + include ApplicationWorker + include ExclusiveLeaseGuard + include SelfMonitoringProjectWorker + + def perform + try_obtain_lease do + Gitlab::DatabaseImporters::SelfMonitoring::Project::CreateService.new.execute + end + end +end diff --git a/app/workers/self_monitoring_project_delete_worker.rb b/app/workers/self_monitoring_project_delete_worker.rb new file mode 100644 index 0000000000000000000000000000000000000000..07a7d3f6c45a5d0c2c61ddcc7887b5c32ac7acaa --- /dev/null +++ b/app/workers/self_monitoring_project_delete_worker.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class SelfMonitoringProjectDeleteWorker + include ApplicationWorker + include ExclusiveLeaseGuard + include SelfMonitoringProjectWorker + + def perform + try_obtain_lease do + Gitlab::DatabaseImporters::SelfMonitoring::Project::DeleteService.new.execute + end + end +end diff --git a/app/workers/stage_update_worker.rb b/app/workers/stage_update_worker.rb index de2454128f65fbd7396b99a90ce745d1e0d208d5..a96c4c6dda269266111a9d209eefd1b97ee84c8f 100644 --- a/app/workers/stage_update_worker.rb +++ b/app/workers/stage_update_worker.rb @@ -7,11 +7,7 @@ class StageUpdateWorker queue_namespace :pipeline_processing latency_sensitive_worker! - # rubocop: disable CodeReuse/ActiveRecord def perform(stage_id) - Ci::Stage.find_by(id: stage_id).try do |stage| - stage.update_status - end + Ci::Stage.find_by_id(stage_id)&.update_legacy_status end - # rubocop: enable CodeReuse/ActiveRecord end diff --git a/changelogs/unreleased/11678-conan-job-tokens.yml b/changelogs/unreleased/11678-conan-job-tokens.yml new file mode 100644 index 0000000000000000000000000000000000000000..8b341ac6f654054a0bcf86ed450f9118c45ef620 --- /dev/null +++ b/changelogs/unreleased/11678-conan-job-tokens.yml @@ -0,0 +1,5 @@ +--- +title: Allow CI_JOB_TOKENS for Conan package registry authentication +merge_request: 22184 +author: +type: added diff --git a/changelogs/unreleased/118442-restyle-changes-header-and-file-tree.yml b/changelogs/unreleased/118442-restyle-changes-header-and-file-tree.yml new file mode 100644 index 0000000000000000000000000000000000000000..8b82d03a503d1a3cb5498c6c5680cd2caaaac427 --- /dev/null +++ b/changelogs/unreleased/118442-restyle-changes-header-and-file-tree.yml @@ -0,0 +1,5 @@ +--- +title: Restyle changes header & file tree +merge_request: 22364 +author: +type: changed diff --git a/changelogs/unreleased/118604-design-view-left-right-keyboard-arrows-through-designs.yml b/changelogs/unreleased/118604-design-view-left-right-keyboard-arrows-through-designs.yml new file mode 100644 index 0000000000000000000000000000000000000000..70c2e1e530876368ac2183ae067d95d13af9e991 --- /dev/null +++ b/changelogs/unreleased/118604-design-view-left-right-keyboard-arrows-through-designs.yml @@ -0,0 +1,5 @@ +--- +title: 'Resolve Design View: Left/Right keyboard arrows through Designs' +merge_request: 22870 +author: +type: added diff --git a/changelogs/unreleased/118627-delete-selected-button-is-incorrectly-active-after-uploading-desig.yml b/changelogs/unreleased/118627-delete-selected-button-is-incorrectly-active-after-uploading-desig.yml new file mode 100644 index 0000000000000000000000000000000000000000..aada5d014a498de99aa1a45fb967ea4b075c32dc --- /dev/null +++ b/changelogs/unreleased/118627-delete-selected-button-is-incorrectly-active-after-uploading-desig.yml @@ -0,0 +1,5 @@ +--- +title: Fix Delete Selected button being active after uploading designs after a deletion +merge_request: 22516 +author: +type: fixed diff --git a/changelogs/unreleased/118640_add_gitlab_version_and_revision_to_export.yml b/changelogs/unreleased/118640_add_gitlab_version_and_revision_to_export.yml new file mode 100644 index 0000000000000000000000000000000000000000..416f241a72627bb5b465312c5f6536dc2c0b561c --- /dev/null +++ b/changelogs/unreleased/118640_add_gitlab_version_and_revision_to_export.yml @@ -0,0 +1,5 @@ +--- +title: Add Gitlab version and revision to export +merge_request: 22108 +author: +type: added diff --git a/changelogs/unreleased/118662-drop-support-es-v5-support-v7.yml b/changelogs/unreleased/118662-drop-support-es-v5-support-v7.yml new file mode 100644 index 0000000000000000000000000000000000000000..f06f2b9e95e8c46892e95b57314577ae858967c8 --- /dev/null +++ b/changelogs/unreleased/118662-drop-support-es-v5-support-v7.yml @@ -0,0 +1,5 @@ +--- +title: Drop support for ES5 add support for ES7 +merge_request: 22859 +author: +type: added diff --git a/changelogs/unreleased/118669-blob-preload.yml b/changelogs/unreleased/118669-blob-preload.yml new file mode 100644 index 0000000000000000000000000000000000000000..757ae0154dbfc79ea0f269be16999ad48d627915 --- /dev/null +++ b/changelogs/unreleased/118669-blob-preload.yml @@ -0,0 +1,5 @@ +--- +title: Fix slow query on blob search when doing path filtering +merge_request: 21996 +author: +type: performance diff --git a/changelogs/unreleased/118816-remove-translation-from-constant.yml b/changelogs/unreleased/118816-remove-translation-from-constant.yml new file mode 100644 index 0000000000000000000000000000000000000000..74167153ce3af528276a7ce35280329406a9f7e1 --- /dev/null +++ b/changelogs/unreleased/118816-remove-translation-from-constant.yml @@ -0,0 +1,5 @@ +--- +title: Fix rebase error message translation in merge requests +merge_request: 22952 +author: briankabiro +type: fixed diff --git a/changelogs/unreleased/119031-add-tags-to-sentry-error-api-rest.yml b/changelogs/unreleased/119031-add-tags-to-sentry-error-api-rest.yml new file mode 100644 index 0000000000000000000000000000000000000000..19713a6fea261ad54d19e05b86e00dc266cee3af --- /dev/null +++ b/changelogs/unreleased/119031-add-tags-to-sentry-error-api-rest.yml @@ -0,0 +1,5 @@ +--- +title: Add tags to sentry detailed error response +merge_request: 22068 +author: +type: added diff --git a/changelogs/unreleased/119198-chat_notification-sidekiq-job-latency-has-increased.yml b/changelogs/unreleased/119198-chat_notification-sidekiq-job-latency-has-increased.yml new file mode 100644 index 0000000000000000000000000000000000000000..158f062b3d612e663e66f70bb56d09cada390ed9 --- /dev/null +++ b/changelogs/unreleased/119198-chat_notification-sidekiq-job-latency-has-increased.yml @@ -0,0 +1,5 @@ +--- +title: Limit the amount of time ChatNotificationWorker waits for the build trace +merge_request: 22132 +author: +type: fixed diff --git a/changelogs/unreleased/119205-500-when-error-stack-trace-is-empty.yml b/changelogs/unreleased/119205-500-when-error-stack-trace-is-empty.yml new file mode 100644 index 0000000000000000000000000000000000000000..e0f5cc1e631c44a475fe10e3ec2ffd98202a7ee3 --- /dev/null +++ b/changelogs/unreleased/119205-500-when-error-stack-trace-is-empty.yml @@ -0,0 +1,5 @@ +--- +title: Fix for 500 when error stack trace is empty +merge_request: 119205 +author: +type: fixed diff --git a/changelogs/unreleased/121670-redis-cache-read-error-prevents-cas-users-from-remaining-signed-in.yml b/changelogs/unreleased/121670-redis-cache-read-error-prevents-cas-users-from-remaining-signed-in.yml new file mode 100644 index 0000000000000000000000000000000000000000..3a54a6dce20514a276a7061c120242fe54a018b1 --- /dev/null +++ b/changelogs/unreleased/121670-redis-cache-read-error-prevents-cas-users-from-remaining-signed-in.yml @@ -0,0 +1,5 @@ +--- +title: Fix CAS users being signed out repeatedly +merge_request: 22704 +author: +type: fixed diff --git a/changelogs/unreleased/121751-new-eks-cluster-results-in-error-unknown-keyword-region.yml b/changelogs/unreleased/121751-new-eks-cluster-results-in-error-unknown-keyword-region.yml new file mode 100644 index 0000000000000000000000000000000000000000..09cd2f00fb51b58ccb2ff7775127fd9b58339252 --- /dev/null +++ b/changelogs/unreleased/121751-new-eks-cluster-results-in-error-unknown-keyword-region.yml @@ -0,0 +1,5 @@ +--- +title: Remove unused keyword from EKS provision service +merge_request: 22633 +author: +type: fixed diff --git a/changelogs/unreleased/121931-replace-font-awesome-cog-icon-with-gitlab-settings-icon.yml b/changelogs/unreleased/121931-replace-font-awesome-cog-icon-with-gitlab-settings-icon.yml new file mode 100644 index 0000000000000000000000000000000000000000..fedd90efd093ddfe7ba37fde8f45a2dcfba4c6b5 --- /dev/null +++ b/changelogs/unreleased/121931-replace-font-awesome-cog-icon-with-gitlab-settings-icon.yml @@ -0,0 +1,5 @@ +--- +title: Replace Font Awesome cog icon with GitLab settings icon +merge_request: 22259 +author: +type: other diff --git a/changelogs/unreleased/13490-design-view-needs-more-information-pt-1.yml b/changelogs/unreleased/13490-design-view-needs-more-information-pt-1.yml new file mode 100644 index 0000000000000000000000000000000000000000..68b068d74db19379d0ff3432c4afb1825a772788 --- /dev/null +++ b/changelogs/unreleased/13490-design-view-needs-more-information-pt-1.yml @@ -0,0 +1,5 @@ +--- +title: Extend Design view sidebar with issue link and a list of participants +merge_request: 22103 +author: +type: added diff --git a/changelogs/unreleased/14857-activate-promethus-integration-for-projects.yml b/changelogs/unreleased/14857-activate-promethus-integration-for-projects.yml new file mode 100644 index 0000000000000000000000000000000000000000..a83008ee848902ec504e4da7de254cd1b2c0a13f --- /dev/null +++ b/changelogs/unreleased/14857-activate-promethus-integration-for-projects.yml @@ -0,0 +1,5 @@ +--- +title: Migrate the database to activate projects prometheus service integration for projects with prometheus installed on shared k8s cluster. +merge_request: 19956 +author: +type: fixed diff --git a/changelogs/unreleased/15082-restrict_project_forks.yml b/changelogs/unreleased/15082-restrict_project_forks.yml new file mode 100644 index 0000000000000000000000000000000000000000..a178e2108c265cd6751a9d07580d9066162fe29e --- /dev/null +++ b/changelogs/unreleased/15082-restrict_project_forks.yml @@ -0,0 +1,5 @@ +--- +title: Add an option to configure forking restriction +merge_request: 17988 +author: +type: added diff --git a/changelogs/unreleased/15398-expiration-policies-update-api.yml b/changelogs/unreleased/15398-expiration-policies-update-api.yml new file mode 100644 index 0000000000000000000000000000000000000000..250d3052a5dd31089605218ed60728b24a305bb9 --- /dev/null +++ b/changelogs/unreleased/15398-expiration-policies-update-api.yml @@ -0,0 +1,5 @@ +--- +title: Container expiration policies can be updated with the project api +merge_request: 22180 +author: +type: added diff --git a/changelogs/unreleased/15398-recurring-job.yml b/changelogs/unreleased/15398-recurring-job.yml new file mode 100644 index 0000000000000000000000000000000000000000..b19983e81838ece911e6ae6057676ebe3c98186e --- /dev/null +++ b/changelogs/unreleased/15398-recurring-job.yml @@ -0,0 +1,5 @@ +--- +title: Add a cron job and worker to run the Container Expiration Policies +merge_request: 21593 +author: +type: added diff --git a/changelogs/unreleased/16610-gitlab-pages-storage-size-limitations-by-project-or-group-4.yml b/changelogs/unreleased/16610-gitlab-pages-storage-size-limitations-by-project-or-group-4.yml new file mode 100644 index 0000000000000000000000000000000000000000..039656ce20aefe31a7c09cb4c56d889d35912c9d --- /dev/null +++ b/changelogs/unreleased/16610-gitlab-pages-storage-size-limitations-by-project-or-group-4.yml @@ -0,0 +1,5 @@ +--- +title: Fix pages size limit setting in database if it is above the hard limit +merge_request: 20154 +author: +type: fixed diff --git a/changelogs/unreleased/18126-change-tag-url-for-tag-push-events-in-chat-msg-integration.yaml b/changelogs/unreleased/18126-change-tag-url-for-tag-push-events-in-chat-msg-integration.yaml deleted file mode 100644 index c576b57f7cb6043276a534ca850eb66605f58f4b..0000000000000000000000000000000000000000 --- a/changelogs/unreleased/18126-change-tag-url-for-tag-push-events-in-chat-msg-integration.yaml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: "Show tag link whenever it's a tag in chat message integration for push events and pipeline events" -merge_request: 18126 -author: Mats Estensen -type: fixed diff --git a/changelogs/unreleased/18999-add-dashboard-activity-to-events-endpoint.yml b/changelogs/unreleased/18999-add-dashboard-activity-to-events-endpoint.yml new file mode 100644 index 0000000000000000000000000000000000000000..62f7b9d5602f4e679b5ad137a824fca0bcbfd03d --- /dev/null +++ b/changelogs/unreleased/18999-add-dashboard-activity-to-events-endpoint.yml @@ -0,0 +1,5 @@ +--- +title: Add activity across all projects to /events endpoint +merge_request: 19816 +author: briankabiro +type: changed diff --git a/changelogs/unreleased/19011-add-operator-dropdown.yml b/changelogs/unreleased/19011-add-operator-dropdown.yml new file mode 100644 index 0000000000000000000000000000000000000000..526ede68c309156a4b8a72a7cfe3a777d9643499 --- /dev/null +++ b/changelogs/unreleased/19011-add-operator-dropdown.yml @@ -0,0 +1,5 @@ +--- +title: Add support for operator in filter bar +merge_request: 19011 +author: +type: added diff --git a/changelogs/unreleased/19132-comment-anchor-twice-firefox.yml b/changelogs/unreleased/19132-comment-anchor-twice-firefox.yml new file mode 100644 index 0000000000000000000000000000000000000000..02a7e1007b39ad3865cbf3f9695d177cbb39a35f --- /dev/null +++ b/changelogs/unreleased/19132-comment-anchor-twice-firefox.yml @@ -0,0 +1,5 @@ +--- +title: Fix bug when clicking on same note twice in Firefox +merge_request: 21699 +author: Jan Beckmann +type: fixed diff --git a/changelogs/unreleased/19299-blame-previous-revision.yml b/changelogs/unreleased/19299-blame-previous-revision.yml new file mode 100644 index 0000000000000000000000000000000000000000..2b99dfb76f8d6399264ef7899df1fa36f40eb677 --- /dev/null +++ b/changelogs/unreleased/19299-blame-previous-revision.yml @@ -0,0 +1,5 @@ +--- +title: Add previous revision link to blame +merge_request: 17088 +author: Hiroyuki Sato +type: added diff --git a/changelogs/unreleased/194066-add-index-to-help-hashed-storage-migration-on-big-instances.yml b/changelogs/unreleased/194066-add-index-to-help-hashed-storage-migration-on-big-instances.yml new file mode 100644 index 0000000000000000000000000000000000000000..ea1b4d795656297dbe06e668a25aac8c97e4bd68 --- /dev/null +++ b/changelogs/unreleased/194066-add-index-to-help-hashed-storage-migration-on-big-instances.yml @@ -0,0 +1,5 @@ +--- +title: Add Index to help Hashed Storage migration on big instances +merge_request: 22391 +author: +type: performance diff --git a/changelogs/unreleased/194144-speed-up-url-helpers.yml b/changelogs/unreleased/194144-speed-up-url-helpers.yml new file mode 100644 index 0000000000000000000000000000000000000000..271dacefe353e30c5dd959878d7707fc08bf67ed --- /dev/null +++ b/changelogs/unreleased/194144-speed-up-url-helpers.yml @@ -0,0 +1,5 @@ +--- +title: Improve link generation performance +merge_request: 22426 +author: +type: performance diff --git a/changelogs/unreleased/194764-fix-resolve-thread-in-new-issue.yml b/changelogs/unreleased/194764-fix-resolve-thread-in-new-issue.yml new file mode 100644 index 0000000000000000000000000000000000000000..360bf31b3becf71d039ec83a48424712bf9f00aa --- /dev/null +++ b/changelogs/unreleased/194764-fix-resolve-thread-in-new-issue.yml @@ -0,0 +1,5 @@ +--- +title: Avoid pre-populating form for MR resolve issues +merge_request: 22593 +author: +type: fixed diff --git a/changelogs/unreleased/195137-update-webpack-to-4-41-5.yml b/changelogs/unreleased/195137-update-webpack-to-4-41-5.yml new file mode 100644 index 0000000000000000000000000000000000000000..3f997f185e237e802d0209677ed4761405c90603 --- /dev/null +++ b/changelogs/unreleased/195137-update-webpack-to-4-41-5.yml @@ -0,0 +1,5 @@ +--- +title: Update webpack from 4.40.2 to 4.41.5 +merge_request: 22452 +author: Takuya Noguchi +type: security diff --git a/changelogs/unreleased/195625-wiki-support-for-asciidoc-include-directive.yml b/changelogs/unreleased/195625-wiki-support-for-asciidoc-include-directive.yml new file mode 100644 index 0000000000000000000000000000000000000000..2e9c65f35af1877ebfba0527fb0e1532ff1f1326 --- /dev/null +++ b/changelogs/unreleased/195625-wiki-support-for-asciidoc-include-directive.yml @@ -0,0 +1,5 @@ +--- +title: Fix error in Wiki when rendering the AsciiDoc include directive +merge_request: 22565 +author: +type: fixed diff --git a/changelogs/unreleased/195776-fix-discard-rename-web-ide.yml b/changelogs/unreleased/195776-fix-discard-rename-web-ide.yml new file mode 100644 index 0000000000000000000000000000000000000000..5780a07a0473e8b508fc373691ac71eec8723a66 --- /dev/null +++ b/changelogs/unreleased/195776-fix-discard-rename-web-ide.yml @@ -0,0 +1,5 @@ +--- +title: Fix discarding renamed directories in Web IDE +merge_request: 22943 +author: +type: fixed diff --git a/changelogs/unreleased/195998-fix-build-prerequisite-transition.yml b/changelogs/unreleased/195998-fix-build-prerequisite-transition.yml new file mode 100644 index 0000000000000000000000000000000000000000..6c306cc15562d6519f02627ab919a72f348e6c0a --- /dev/null +++ b/changelogs/unreleased/195998-fix-build-prerequisite-transition.yml @@ -0,0 +1,5 @@ +--- +title: Prevent builds from halting unnecessarily when completing prerequisites +merge_request: 22938 +author: +type: fixed diff --git a/changelogs/unreleased/196158-fix-no-redirect-case-for-docker-blob-replication.yml b/changelogs/unreleased/196158-fix-no-redirect-case-for-docker-blob-replication.yml new file mode 100644 index 0000000000000000000000000000000000000000..947836321b6eaffff3ee6e5d84a542f67701f307 --- /dev/null +++ b/changelogs/unreleased/196158-fix-no-redirect-case-for-docker-blob-replication.yml @@ -0,0 +1,5 @@ +--- +title: 'Geo: Fix Docker repository synchronization for local storage' +merge_request: 22981 +author: +type: fixed diff --git a/changelogs/unreleased/196158-fix-tags-nil.yml b/changelogs/unreleased/196158-fix-tags-nil.yml new file mode 100644 index 0000000000000000000000000000000000000000..5a7dd3177eaa7c67a6d40ec07ca45d8803712996 --- /dev/null +++ b/changelogs/unreleased/196158-fix-tags-nil.yml @@ -0,0 +1,5 @@ +--- +title: 'Geo: Handle repositories in Docker Registry with no tags gracefully' +merge_request: 23022 +author: +type: fixed diff --git a/changelogs/unreleased/196172-issue-create-iid-conflict.yml b/changelogs/unreleased/196172-issue-create-iid-conflict.yml new file mode 100644 index 0000000000000000000000000000000000000000..bb0ea686f912f425863673b74821b750a71f8eaa --- /dev/null +++ b/changelogs/unreleased/196172-issue-create-iid-conflict.yml @@ -0,0 +1,6 @@ +--- +title: 'Fix Issue API: creating with manual IID returns conflict when IID already + in use' +merge_request: 22788 +author: Mara Sophie Grosch +type: fixed diff --git a/changelogs/unreleased/196254-update-label-text.yml b/changelogs/unreleased/196254-update-label-text.yml new file mode 100644 index 0000000000000000000000000000000000000000..a44ab941b1438771c8240b628e37545aa35ed9a1 --- /dev/null +++ b/changelogs/unreleased/196254-update-label-text.yml @@ -0,0 +1,5 @@ +--- +title: Update button label in MR widget pipeline footer +merge_request: 22900 +author: +type: changed diff --git a/changelogs/unreleased/19688-allow-org-mode-in-wiki.yml b/changelogs/unreleased/19688-allow-org-mode-in-wiki.yml new file mode 100644 index 0000000000000000000000000000000000000000..2b7e02a998baf90fa2982acbbb638c6d3ff2f0d0 --- /dev/null +++ b/changelogs/unreleased/19688-allow-org-mode-in-wiki.yml @@ -0,0 +1,5 @@ +--- +title: Add Org to the list of available markups for project wikis +merge_request: 22898 +author: Alexander Oleynikov +type: added diff --git a/changelogs/unreleased/197143-nomethoderror-undefined-method-sort_by_attribute-for-nil-nilclass.yml b/changelogs/unreleased/197143-nomethoderror-undefined-method-sort_by_attribute-for-nil-nilclass.yml new file mode 100644 index 0000000000000000000000000000000000000000..843e611b06c7b3bb98fb00aecc125f0806a642c4 --- /dev/null +++ b/changelogs/unreleased/197143-nomethoderror-undefined-method-sort_by_attribute-for-nil-nilclass.yml @@ -0,0 +1,6 @@ +--- +title: Add returning relation from GroupMembersFinder if called on root group with + only inherited param +merge_request: 23161 +author: +type: fixed diff --git a/changelogs/unreleased/197146-expose-active-field-in-the-error-tracking-api.yml b/changelogs/unreleased/197146-expose-active-field-in-the-error-tracking-api.yml new file mode 100644 index 0000000000000000000000000000000000000000..56860e89d0c343c5a6bafd1e1e94ee1cf35ebfda --- /dev/null +++ b/changelogs/unreleased/197146-expose-active-field-in-the-error-tracking-api.yml @@ -0,0 +1,5 @@ +--- +title: Expose `active` field in the Error Tracking API +merge_request: 23150 +author: +type: added diff --git a/changelogs/unreleased/197343-iscompact.yml b/changelogs/unreleased/197343-iscompact.yml new file mode 100644 index 0000000000000000000000000000000000000000..d85e729d2bd853b86e735b545ef3ed8b8f730917 --- /dev/null +++ b/changelogs/unreleased/197343-iscompact.yml @@ -0,0 +1,5 @@ +--- +title: Fix unexpected behaviour of the commit form after committing in Web IDE +merge_request: 23238 +author: +type: fixed diff --git a/changelogs/unreleased/20709-add-runner-info-in-build-event.yaml b/changelogs/unreleased/20709-add-runner-info-in-build-event.yaml deleted file mode 100644 index 4a9d79d7299febd260e9290446d50baef9b37469..0000000000000000000000000000000000000000 --- a/changelogs/unreleased/20709-add-runner-info-in-build-event.yaml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Add runner information in build web hook event -merge_request: 20709 -author: Gaetan Semet -type: added diff --git a/changelogs/unreleased/20956-autostop-frontend.yml b/changelogs/unreleased/20956-autostop-frontend.yml new file mode 100644 index 0000000000000000000000000000000000000000..e31f1033c7a7d5c2e15c955af502128b0dd1430e --- /dev/null +++ b/changelogs/unreleased/20956-autostop-frontend.yml @@ -0,0 +1,5 @@ +--- +title: Auto stop environments after a certain period +merge_request: 20372 +author: +type: added diff --git a/changelogs/unreleased/20978-add-allow-failure-in-pipeline-event.yaml b/changelogs/unreleased/20978-add-allow-failure-in-pipeline-event.yaml deleted file mode 100644 index 2a7e731e0bea6047bc56905992ab3b6302fac017..0000000000000000000000000000000000000000 --- a/changelogs/unreleased/20978-add-allow-failure-in-pipeline-event.yaml +++ /dev/null @@ -1,6 +0,0 @@ ---- -title: | - Add allow failure in pipeline webhook event -merge_request: 20978 -author: Gaetan Semet -type: added diff --git a/changelogs/unreleased/22166-fix-sast-template.yml b/changelogs/unreleased/22166-fix-sast-template.yml new file mode 100644 index 0000000000000000000000000000000000000000..0c5e17b38ece128ecdff466a9bd8f170c5d6ea93 --- /dev/null +++ b/changelogs/unreleased/22166-fix-sast-template.yml @@ -0,0 +1,5 @@ +--- +title: Check both SAST_DISABLE and SAST_DISABLE_DIND when executing SAST job template +merge_request: 22166 +author: +type: fixed diff --git a/changelogs/unreleased/22171-fix-disabling-dependency-scanning.yml b/changelogs/unreleased/22171-fix-disabling-dependency-scanning.yml new file mode 100644 index 0000000000000000000000000000000000000000..f12d460a16aab6be831e49c0da2cb623c8acf5b3 --- /dev/null +++ b/changelogs/unreleased/22171-fix-disabling-dependency-scanning.yml @@ -0,0 +1,5 @@ +--- +title: Check both DEPENDENCY_SCANNING_DISABLED and DS_DISABLE_DIND when executing Dependency Scanning job template +merge_request: 22172 +author: +type: fixed diff --git a/changelogs/unreleased/22327-add-ci-server-url.yml b/changelogs/unreleased/22327-add-ci-server-url.yml new file mode 100644 index 0000000000000000000000000000000000000000..1103d2538db8741d91da84026e5b181048308d68 --- /dev/null +++ b/changelogs/unreleased/22327-add-ci-server-url.yml @@ -0,0 +1,5 @@ +--- +title: Add CI variable to provide GitLab base URL +merge_request: 22327 +author: Aidin Abedi +type: added diff --git a/changelogs/unreleased/22465-rack-attack-authenticate-job-token-requests.yml b/changelogs/unreleased/22465-rack-attack-authenticate-job-token-requests.yml new file mode 100644 index 0000000000000000000000000000000000000000..19cc7b833852955462ccc030fa96ebba40c29270 --- /dev/null +++ b/changelogs/unreleased/22465-rack-attack-authenticate-job-token-requests.yml @@ -0,0 +1,5 @@ +--- +title: Authenticate API requests with job tokens for Rack::Attack +merge_request: 21412 +author: +type: fixed diff --git a/changelogs/unreleased/22571-inconsistent-real-time-checkbox.yml b/changelogs/unreleased/22571-inconsistent-real-time-checkbox.yml new file mode 100644 index 0000000000000000000000000000000000000000..58e77e94eda94ec8af83016c4e49b3afbe093845 --- /dev/null +++ b/changelogs/unreleased/22571-inconsistent-real-time-checkbox.yml @@ -0,0 +1,5 @@ +--- +title: Tasks in HTML comments are no longer incorrectly detected +merge_request: 21434 +author: +type: fixed diff --git a/changelogs/unreleased/22776-update-project-regex.yml b/changelogs/unreleased/22776-update-project-regex.yml new file mode 100644 index 0000000000000000000000000000000000000000..0866e308418f2f82234449da71bd0f228249a06b --- /dev/null +++ b/changelogs/unreleased/22776-update-project-regex.yml @@ -0,0 +1,5 @@ +--- +title: Allow Unicode 11 emojis in project names +merge_request: 22776 +author: Harm Berntsen +type: changed diff --git a/changelogs/unreleased/22986-share_group_with_group_ff_default_on.yml b/changelogs/unreleased/22986-share_group_with_group_ff_default_on.yml new file mode 100644 index 0000000000000000000000000000000000000000..f271c9c89aca0367a3135eee93247b14006ef7e0 --- /dev/null +++ b/changelogs/unreleased/22986-share_group_with_group_ff_default_on.yml @@ -0,0 +1,5 @@ +--- +title: Allow to share groups with other groups +merge_request: 23185 +author: +type: changed diff --git a/changelogs/unreleased/24190-archived-project-warning-message-on-discussion-thread.yml b/changelogs/unreleased/24190-archived-project-warning-message-on-discussion-thread.yml new file mode 100644 index 0000000000000000000000000000000000000000..16f851e6bdb39b51b90d30a92edeb8d6b4ae529e --- /dev/null +++ b/changelogs/unreleased/24190-archived-project-warning-message-on-discussion-thread.yml @@ -0,0 +1,5 @@ +--- +title: Display login or register widget only if user is not logged in +merge_request: 22211 +author: +type: fixed diff --git a/changelogs/unreleased/24305-create-new-project-auto-populate-project-slug-string-to-project-nam.yml b/changelogs/unreleased/24305-create-new-project-auto-populate-project-slug-string-to-project-nam.yml new file mode 100644 index 0000000000000000000000000000000000000000..8e4e95540f15f93807205102ef36f6bc7407f093 --- /dev/null +++ b/changelogs/unreleased/24305-create-new-project-auto-populate-project-slug-string-to-project-nam.yml @@ -0,0 +1,6 @@ +--- +title: 'Resolve Create new project: Auto-populate project slug string to project name + if name is empty' +merge_request: 22627 +author: +type: changed diff --git a/changelogs/unreleased/24605-allow-admins-to-disable-users-ability-to-change-profile-name.yml b/changelogs/unreleased/24605-allow-admins-to-disable-users-ability-to-change-profile-name.yml new file mode 100644 index 0000000000000000000000000000000000000000..5585243cfc7b8ebba293663fdba820e7b413de7f --- /dev/null +++ b/changelogs/unreleased/24605-allow-admins-to-disable-users-ability-to-change-profile-name.yml @@ -0,0 +1,5 @@ +--- +title: Allow admins to disable users ability to change profile name +merge_request: 21987 +author: +type: added diff --git a/changelogs/unreleased/25250-can-t-use-quick-action-to-set-milestone-when-the-project-only-has-m.yml b/changelogs/unreleased/25250-can-t-use-quick-action-to-set-milestone-when-the-project-only-has-m.yml new file mode 100644 index 0000000000000000000000000000000000000000..6fba3f2b96f5e3cbecb5876cba61edba5d91dfdc --- /dev/null +++ b/changelogs/unreleased/25250-can-t-use-quick-action-to-set-milestone-when-the-project-only-has-m.yml @@ -0,0 +1,5 @@ +--- +title: Fix milestone quick action to handle ancestor group milestones +merge_request: 22231 +author: +type: fixed diff --git a/changelogs/unreleased/25343-show-readme-txt.yml b/changelogs/unreleased/25343-show-readme-txt.yml new file mode 100644 index 0000000000000000000000000000000000000000..d4b450beb0a355492a41c1b9499b9f849eb95919 --- /dev/null +++ b/changelogs/unreleased/25343-show-readme-txt.yml @@ -0,0 +1,5 @@ +--- +title: Fix README.txt not showing up on a project page +merge_request: 21763 +author: Alexander Oleynikov +type: fixed diff --git a/changelogs/unreleased/26543-add-word-diff-highlight-to-rich-text.yml b/changelogs/unreleased/26543-add-word-diff-highlight-to-rich-text.yml new file mode 100644 index 0000000000000000000000000000000000000000..cea1158d9ef5f7b7ce02bf431e83a06e023b4ae2 --- /dev/null +++ b/changelogs/unreleased/26543-add-word-diff-highlight-to-rich-text.yml @@ -0,0 +1,5 @@ +--- +title: Apply word-diff highlighting to Suggestions +merge_request: 22182 +author: +type: changed diff --git a/changelogs/unreleased/27244-discard-fix.yml b/changelogs/unreleased/27244-discard-fix.yml new file mode 100644 index 0000000000000000000000000000000000000000..3c86a6d3f850f4c2ee87b348db03851f566f6b1f --- /dev/null +++ b/changelogs/unreleased/27244-discard-fix.yml @@ -0,0 +1,5 @@ +--- +title: Fix "Discard" for newly-created and renamed files +merge_request: 21905 +author: +type: fixed diff --git a/changelogs/unreleased/27296-incorrect-task-list-checked-with-embedded-subtasks.yml b/changelogs/unreleased/27296-incorrect-task-list-checked-with-embedded-subtasks.yml new file mode 100644 index 0000000000000000000000000000000000000000..0da9ebfe8d5babb604a2d6ca800277044ef50501 --- /dev/null +++ b/changelogs/unreleased/27296-incorrect-task-list-checked-with-embedded-subtasks.yml @@ -0,0 +1,5 @@ +--- +title: Properly check a task embedded in a list with no text +merge_request: 21947 +author: +type: fixed diff --git a/changelogs/unreleased/27366-supergroup-milestone-not-showing-in-subgroup-board-issue-list.yml b/changelogs/unreleased/27366-supergroup-milestone-not-showing-in-subgroup-board-issue-list.yml new file mode 100644 index 0000000000000000000000000000000000000000..6fc98aadc79da97c215ef70678c31efdad48ff8e --- /dev/null +++ b/changelogs/unreleased/27366-supergroup-milestone-not-showing-in-subgroup-board-issue-list.yml @@ -0,0 +1,6 @@ +--- +title: Fix group issue list and group issue board filters not showing ancestor group + milestones +merge_request: 23038 +author: +type: fixed diff --git a/changelogs/unreleased/27427-boards-sidebar-icon.yml b/changelogs/unreleased/27427-boards-sidebar-icon.yml new file mode 100644 index 0000000000000000000000000000000000000000..de8a5966d77e4e83608f0895d96d69444ca31eb0 --- /dev/null +++ b/changelogs/unreleased/27427-boards-sidebar-icon.yml @@ -0,0 +1,5 @@ +--- +title: Increase size of issue boards sidebar collapse button +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/27518-revoke-active-sessions.yml b/changelogs/unreleased/27518-revoke-active-sessions.yml new file mode 100644 index 0000000000000000000000000000000000000000..e9fc26c8821c3f3b20ca2aa25227d11f84a34866 --- /dev/null +++ b/changelogs/unreleased/27518-revoke-active-sessions.yml @@ -0,0 +1,6 @@ +--- +title: Restores user's ability to revoke sessions from the active sessions + page. +merge_request: 17462 +author: Jesse Hall @jessehall3 +type: changed diff --git a/changelogs/unreleased/27884-fix-table-styles.yml b/changelogs/unreleased/27884-fix-table-styles.yml new file mode 100644 index 0000000000000000000000000000000000000000..346c7f32e3f144438ebadb7267329fdb37bd966c --- /dev/null +++ b/changelogs/unreleased/27884-fix-table-styles.yml @@ -0,0 +1,5 @@ +--- +title: Fix markdown table border colors +merge_request: 22314 +author: +type: fixed diff --git a/changelogs/unreleased/27946-merge-request-discussions-api-doesn-t-reject-an-error-input-in-some.yml b/changelogs/unreleased/27946-merge-request-discussions-api-doesn-t-reject-an-error-input-in-some.yml new file mode 100644 index 0000000000000000000000000000000000000000..c92ac53d5f843b5678435aeee6b417b7953f652d --- /dev/null +++ b/changelogs/unreleased/27946-merge-request-discussions-api-doesn-t-reject-an-error-input-in-some.yml @@ -0,0 +1,6 @@ +--- +title: Resolve "Merge request discussions API doesn't reject an error input in some + case" +merge_request: 21936 +author: +type: fixed diff --git a/changelogs/unreleased/29403-migrate-issue-tracker-data.yml b/changelogs/unreleased/29403-migrate-issue-tracker-data.yml new file mode 100644 index 0000000000000000000000000000000000000000..9f454640b50315d7d7473dc6b0e76884e4189ef7 --- /dev/null +++ b/changelogs/unreleased/29403-migrate-issue-tracker-data.yml @@ -0,0 +1,5 @@ +--- +title: Migrate issue trackers data +merge_request: 18639 +author: +type: other diff --git a/changelogs/unreleased/29772-completely-removing-use_legacy_pipeline_triggers.yml b/changelogs/unreleased/29772-completely-removing-use_legacy_pipeline_triggers.yml new file mode 100644 index 0000000000000000000000000000000000000000..d9ac063f31097235998db49ca456b91b9f96838a --- /dev/null +++ b/changelogs/unreleased/29772-completely-removing-use_legacy_pipeline_triggers.yml @@ -0,0 +1,5 @@ +--- +title: Remove feature flag 'use_legacy_pipeline_triggers' and remove legacy tokens +merge_request: 21732 +author: +type: removed diff --git a/changelogs/unreleased/30229-background-migration-pruneorphanedgeoevents-did-you-mean-pruneoldev.yml b/changelogs/unreleased/30229-background-migration-pruneorphanedgeoevents-did-you-mean-pruneoldev.yml new file mode 100644 index 0000000000000000000000000000000000000000..8b987c63d966c7652b9967d587d2330cde474829 --- /dev/null +++ b/changelogs/unreleased/30229-background-migration-pruneorphanedgeoevents-did-you-mean-pruneoldev.yml @@ -0,0 +1,5 @@ +--- +title: 'Fix: undefined background migration classes for EE-CE downgrades' +merge_request: 22160 +author: +type: fixed diff --git a/changelogs/unreleased/30395-pages-ssl-limitations-warning.yml b/changelogs/unreleased/30395-pages-ssl-limitations-warning.yml new file mode 100644 index 0000000000000000000000000000000000000000..382e3b8e53259da38640dd9647bae7d339ec40af --- /dev/null +++ b/changelogs/unreleased/30395-pages-ssl-limitations-warning.yml @@ -0,0 +1,6 @@ +--- +title: Display SSL limitations warning for project's pages under namespace that contains + dot +merge_request: 21874 +author: +type: other diff --git a/changelogs/unreleased/30936-ado-quick-start.yml b/changelogs/unreleased/30936-ado-quick-start.yml new file mode 100644 index 0000000000000000000000000000000000000000..24cf5c5f416c335bd2965baf6f230d209582ab84 --- /dev/null +++ b/changelogs/unreleased/30936-ado-quick-start.yml @@ -0,0 +1,5 @@ +--- +title: Adds quickstart doc link to ADO CICD settings +merge_request: +author: +type: changed diff --git a/changelogs/unreleased/30968-make-open-user-registration-setting-more-prominent-to-admins.yml b/changelogs/unreleased/30968-make-open-user-registration-setting-more-prominent-to-admins.yml new file mode 100644 index 0000000000000000000000000000000000000000..dd7909c87561957c5977a083aac85d088025aef0 --- /dev/null +++ b/changelogs/unreleased/30968-make-open-user-registration-setting-more-prominent-to-admins.yml @@ -0,0 +1,5 @@ +--- +title: Add documentation & helper text information regarding securing a GitLab instance +merge_request: 18987 +author: +type: changed diff --git a/changelogs/unreleased/31301-expose-reference-path-in-api-for-issuables.yml b/changelogs/unreleased/31301-expose-reference-path-in-api-for-issuables.yml new file mode 100644 index 0000000000000000000000000000000000000000..f9f41ceb615e3fb09a65e8dfa894fabb47adc1d8 --- /dev/null +++ b/changelogs/unreleased/31301-expose-reference-path-in-api-for-issuables.yml @@ -0,0 +1,5 @@ +--- +title: Expose full reference path for issuables in API +merge_request: 20354 +author: +type: changed diff --git a/changelogs/unreleased/31475-fix-metrics-for-env-status.yml b/changelogs/unreleased/31475-fix-metrics-for-env-status.yml new file mode 100644 index 0000000000000000000000000000000000000000..b063716e9283e6191ffeb24db30464ab61ae4006 --- /dev/null +++ b/changelogs/unreleased/31475-fix-metrics-for-env-status.yml @@ -0,0 +1,5 @@ +--- +title: Prevent MergeRequestsController#ci_environment_status.json from making HTTP requests +merge_request: 21812 +author: +type: fixed diff --git a/changelogs/unreleased/31859-fix-discarding-renamed-entry-with-changes.yml b/changelogs/unreleased/31859-fix-discarding-renamed-entry-with-changes.yml new file mode 100644 index 0000000000000000000000000000000000000000..f5baaab2382dc6076f16d83ec0d8537ff8ab49a3 --- /dev/null +++ b/changelogs/unreleased/31859-fix-discarding-renamed-entry-with-changes.yml @@ -0,0 +1,5 @@ +--- +title: Update IDE discard of renamed entry to also discard file changes +merge_request: 22573 +author: +type: fixed diff --git a/changelogs/unreleased/32095-allow-administrators-to-disable-gitlab-pages-access.yml b/changelogs/unreleased/32095-allow-administrators-to-disable-gitlab-pages-access.yml new file mode 100644 index 0000000000000000000000000000000000000000..6f7dfe812dec2e2ae9b2fb1464efcdc6e6a17025 --- /dev/null +++ b/changelogs/unreleased/32095-allow-administrators-to-disable-gitlab-pages-access.yml @@ -0,0 +1,5 @@ +--- +title: Allow administrators to enforce access control for all pages web-sites +merge_request: 22003 +author: +type: added diff --git a/changelogs/unreleased/32273-fix-choose-template.yml b/changelogs/unreleased/32273-fix-choose-template.yml new file mode 100644 index 0000000000000000000000000000000000000000..384a46f4dae92a9b495ddaf19613ff8ce279cba2 --- /dev/null +++ b/changelogs/unreleased/32273-fix-choose-template.yml @@ -0,0 +1,5 @@ +--- +title: Changes to template dropdown location +merge_request: 22049 +author: +type: changed diff --git a/changelogs/unreleased/32326-cablett-epic-source-fks.yml b/changelogs/unreleased/32326-cablett-epic-source-fks.yml new file mode 100644 index 0000000000000000000000000000000000000000..f87b6bf7499eac71b6c5238e68f2fa38a01ed9a0 --- /dev/null +++ b/changelogs/unreleased/32326-cablett-epic-source-fks.yml @@ -0,0 +1,5 @@ +--- +title: Add epic milestone sourcing foreign key +merge_request: 21907 +author: +type: fixed diff --git a/changelogs/unreleased/33467-display-location-instead-of-project-name-in-the-security-project-da.yml b/changelogs/unreleased/33467-display-location-instead-of-project-name-in-the-security-project-da.yml new file mode 100644 index 0000000000000000000000000000000000000000..7f882cc021c1d9bbc409b72622a1fdc0838ac3a9 --- /dev/null +++ b/changelogs/unreleased/33467-display-location-instead-of-project-name-in-the-security-project-da.yml @@ -0,0 +1,5 @@ +--- +title: Display location in the Security Project Dashboard +merge_request: 22376 +author: +type: other diff --git a/changelogs/unreleased/33596-package-ci-package-pipeline-api.yml b/changelogs/unreleased/33596-package-ci-package-pipeline-api.yml new file mode 100644 index 0000000000000000000000000000000000000000..3e5eb961e953255581de36e7d3f652adbc07a9a9 --- /dev/null +++ b/changelogs/unreleased/33596-package-ci-package-pipeline-api.yml @@ -0,0 +1,5 @@ +--- +title: Add build metadata to package API +merge_request: 20682 +author: +type: added diff --git a/changelogs/unreleased/33658-reducing-build-trace-update-frequency.yml b/changelogs/unreleased/33658-reducing-build-trace-update-frequency.yml new file mode 100644 index 0000000000000000000000000000000000000000..aff8ec5c9185f64936acbd3ef358984b7926c3bb --- /dev/null +++ b/changelogs/unreleased/33658-reducing-build-trace-update-frequency.yml @@ -0,0 +1,5 @@ +--- +title: Request less frequent updates from Runner when job log is not being watched +merge_request: 20841 +author: +type: performance diff --git a/changelogs/unreleased/33681-api.yml b/changelogs/unreleased/33681-api.yml new file mode 100644 index 0000000000000000000000000000000000000000..8224949b31fcecd180084dc7710caaecef06db4d --- /dev/null +++ b/changelogs/unreleased/33681-api.yml @@ -0,0 +1,5 @@ +--- +title: Add API for rollout Elasticsearch per plan level +merge_request: 22240 +author: +type: added diff --git a/changelogs/unreleased/33892-add-conan-install-instructions.yml b/changelogs/unreleased/33892-add-conan-install-instructions.yml new file mode 100644 index 0000000000000000000000000000000000000000..0a308dbdaf9d825232e8889094c4c9289d938f8d --- /dev/null +++ b/changelogs/unreleased/33892-add-conan-install-instructions.yml @@ -0,0 +1,5 @@ +--- +title: Added Conan installation instructions to Conan package details page +merge_request: 22390 +author: +type: added diff --git a/changelogs/unreleased/33892-add-conan-recipe-to-package-details.yml b/changelogs/unreleased/33892-add-conan-recipe-to-package-details.yml new file mode 100644 index 0000000000000000000000000000000000000000..a3079f1c902b5ecc106ae90d733a25f3fce4b1db --- /dev/null +++ b/changelogs/unreleased/33892-add-conan-recipe-to-package-details.yml @@ -0,0 +1,5 @@ +--- +title: Added Conan recipe in place of the package name on the package details page. +merge_request: 21247 +author: +type: changed diff --git a/changelogs/unreleased/34522-webide-empty-repos.yml b/changelogs/unreleased/34522-webide-empty-repos.yml new file mode 100644 index 0000000000000000000000000000000000000000..3fbd097dba84a6c6588c6a53eab02a7490231f03 --- /dev/null +++ b/changelogs/unreleased/34522-webide-empty-repos.yml @@ -0,0 +1,5 @@ +--- +title: 'Fix: WebIDE doesn''t work on empty repositories again' +merge_request: 22950 +author: +type: fixed diff --git a/changelogs/unreleased/34625-Remove-IIFEs-from-users_select-js.yml b/changelogs/unreleased/34625-Remove-IIFEs-from-users_select-js.yml new file mode 100644 index 0000000000000000000000000000000000000000..9000ba0b393fa81c3a28bbc019b5f9c7fba82524 --- /dev/null +++ b/changelogs/unreleased/34625-Remove-IIFEs-from-users_select-js.yml @@ -0,0 +1,5 @@ +--- +title: Remove IIFEs from users_select.js +merge_request: 19290 +author: minghuan lei +type: other diff --git a/changelogs/unreleased/34867-epics_to_project_import_export.yml b/changelogs/unreleased/34867-epics_to_project_import_export.yml new file mode 100644 index 0000000000000000000000000000000000000000..a7c2b2e36559b5d39b1f3f70618ce1cd7b3e8ecb --- /dev/null +++ b/changelogs/unreleased/34867-epics_to_project_import_export.yml @@ -0,0 +1,5 @@ +--- +title: Add epics to project import/export +merge_request: 19883 +author: +type: added diff --git a/changelogs/unreleased/35195-graphql-implementation-of-grafana-auth.yml b/changelogs/unreleased/35195-graphql-implementation-of-grafana-auth.yml new file mode 100644 index 0000000000000000000000000000000000000000..5bdd7f07fb10164bf982975b48984cf2aeabd23b --- /dev/null +++ b/changelogs/unreleased/35195-graphql-implementation-of-grafana-auth.yml @@ -0,0 +1,5 @@ +--- +title: Add fetching of Grafana Auth via the GraphQL API +merge_request: 21756 +author: +type: changed diff --git a/changelogs/unreleased/35242-add-liquid-template.yml b/changelogs/unreleased/35242-add-liquid-template.yml new file mode 100644 index 0000000000000000000000000000000000000000..34f3ad3e758506dd80bfb50338af1815d14fbd46 --- /dev/null +++ b/changelogs/unreleased/35242-add-liquid-template.yml @@ -0,0 +1,5 @@ +--- +title: Add support for Liquid format in Prometheus queries +merge_request: 20793 +author: +type: added diff --git a/changelogs/unreleased/35305-hide-unauthorized-mirroring-actions.yml b/changelogs/unreleased/35305-hide-unauthorized-mirroring-actions.yml new file mode 100644 index 0000000000000000000000000000000000000000..09fd9d5cf96bd9124516b71dbc662c222e900a82 --- /dev/null +++ b/changelogs/unreleased/35305-hide-unauthorized-mirroring-actions.yml @@ -0,0 +1,5 @@ +--- +title: Hide mirror admin actions from developers +merge_request: 21569 +author: +type: fixed diff --git a/changelogs/unreleased/35527-add-created-at-to-package.yml b/changelogs/unreleased/35527-add-created-at-to-package.yml new file mode 100644 index 0000000000000000000000000000000000000000..fa127f8b4b267cd8062ae9e5a0ed6f562f77a083 --- /dev/null +++ b/changelogs/unreleased/35527-add-created-at-to-package.yml @@ -0,0 +1,5 @@ +--- +title: Adds created_at object to package api response +merge_request: 20816 +author: +type: added diff --git a/changelogs/unreleased/35673-add-gitlab-runner-ci-template.yml b/changelogs/unreleased/35673-add-gitlab-runner-ci-template.yml new file mode 100644 index 0000000000000000000000000000000000000000..a0eaf9823d7dd4f29e36e10fb4b6c227bcf0b504 --- /dev/null +++ b/changelogs/unreleased/35673-add-gitlab-runner-ci-template.yml @@ -0,0 +1,5 @@ +--- +title: Bump cluster-applications image to v0.5.0 (Adds GitLab Runner support) +merge_request: 23110 +author: +type: added diff --git a/changelogs/unreleased/35739-add-mr-to-deployment-api.yml b/changelogs/unreleased/35739-add-mr-to-deployment-api.yml new file mode 100644 index 0000000000000000000000000000000000000000..13dabb2fae1772b09252aab52f807b80bfbd45b8 --- /dev/null +++ b/changelogs/unreleased/35739-add-mr-to-deployment-api.yml @@ -0,0 +1,5 @@ +--- +title: Add API support for retrieving merge requests deployed in a deployment +merge_request: 21837 +author: +type: added diff --git a/changelogs/unreleased/36017-move-analytics-out-of-more-in-top-navbar.yml b/changelogs/unreleased/36017-move-analytics-out-of-more-in-top-navbar.yml new file mode 100644 index 0000000000000000000000000000000000000000..c4cf6d4c174d8fe8aad0f04a0a27dc5cb3ea483a --- /dev/null +++ b/changelogs/unreleased/36017-move-analytics-out-of-more-in-top-navbar.yml @@ -0,0 +1,5 @@ +--- +title: Move instance statistics into analytics namespace +merge_request: 21112 +author: +type: changed diff --git a/changelogs/unreleased/36032-multiple-milestone-storage.yml b/changelogs/unreleased/36032-multiple-milestone-storage.yml new file mode 100644 index 0000000000000000000000000000000000000000..ac373b4442a1e149ba41da6814c2679c7ff88b55 --- /dev/null +++ b/changelogs/unreleased/36032-multiple-milestone-storage.yml @@ -0,0 +1,5 @@ +--- +title: Setup storage for multiple milestones +merge_request: 22043 +author: +type: added diff --git a/changelogs/unreleased/36235-services-usage-ping.yml b/changelogs/unreleased/36235-services-usage-ping.yml new file mode 100644 index 0000000000000000000000000000000000000000..2e4f7b5c29cdf2bad8668a16691043eebc8eab19 --- /dev/null +++ b/changelogs/unreleased/36235-services-usage-ping.yml @@ -0,0 +1,5 @@ +--- +title: Add remaining project services to usage ping +merge_request: 21843 +author: +type: added diff --git a/changelogs/unreleased/36246-add-language-and-severity-to-sentry-details.yml b/changelogs/unreleased/36246-add-language-and-severity-to-sentry-details.yml new file mode 100644 index 0000000000000000000000000000000000000000..1dfebb187268ceba94152a5fd91e4b49a4095b2d --- /dev/null +++ b/changelogs/unreleased/36246-add-language-and-severity-to-sentry-details.yml @@ -0,0 +1,5 @@ +--- +title: Add language and error urgency level for Sentry issue details page +merge_request: 22122 +author: +type: added diff --git a/changelogs/unreleased/36410-error-list-mobile.yml b/changelogs/unreleased/36410-error-list-mobile.yml new file mode 100644 index 0000000000000000000000000000000000000000..759fe4ed76df1ac6935976a5329bdb54cce62727 --- /dev/null +++ b/changelogs/unreleased/36410-error-list-mobile.yml @@ -0,0 +1,5 @@ +--- +title: Improve error list UI on mobile viewports +merge_request: 21192 +author: +type: added diff --git a/changelogs/unreleased/36545-frontend-add-stack-trace-component-below-details-when-issue-is-crea.yml b/changelogs/unreleased/36545-frontend-add-stack-trace-component-below-details-when-issue-is-crea.yml new file mode 100644 index 0000000000000000000000000000000000000000..0489c0878668df5066791ca3c48dea5f3b78620a --- /dev/null +++ b/changelogs/unreleased/36545-frontend-add-stack-trace-component-below-details-when-issue-is-crea.yml @@ -0,0 +1,5 @@ +--- +title: Add stacktrace to issue created from the sentry error detail page +merge_request: 21438 +author: +type: added diff --git a/changelogs/unreleased/36667-add-release-count-to-project-homepage.yml b/changelogs/unreleased/36667-add-release-count-to-project-homepage.yml new file mode 100644 index 0000000000000000000000000000000000000000..3a8e26b00875baa41b1a9cd4e7b16a85672c31c7 --- /dev/null +++ b/changelogs/unreleased/36667-add-release-count-to-project-homepage.yml @@ -0,0 +1,5 @@ +--- +title: Add release count to project homepage +merge_request: 21350 +author: +type: added diff --git a/changelogs/unreleased/36716-issue-reference-changes-when-adding-to-epic.yml b/changelogs/unreleased/36716-issue-reference-changes-when-adding-to-epic.yml new file mode 100644 index 0000000000000000000000000000000000000000..dc58c0e51ff76444e6b7acc27add3108c9a4b980 --- /dev/null +++ b/changelogs/unreleased/36716-issue-reference-changes-when-adding-to-epic.yml @@ -0,0 +1,5 @@ +--- +title: Fix relative links in Slack message +merge_request: 22608 +author: +type: fixed diff --git a/changelogs/unreleased/36753-dashes-not-supported-in-cn-for-ldap-group-sync-on-first-login.yml b/changelogs/unreleased/36753-dashes-not-supported-in-cn-for-ldap-group-sync-on-first-login.yml new file mode 100644 index 0000000000000000000000000000000000000000..93f3cce4376d5a38e1a0647b460bc577a1760636 --- /dev/null +++ b/changelogs/unreleased/36753-dashes-not-supported-in-cn-for-ldap-group-sync-on-first-login.yml @@ -0,0 +1,5 @@ +--- +title: Support dashes in LDAP group CN for sync on users first log in +merge_request: 20402 +author: +type: fixed diff --git a/changelogs/unreleased/36955-snowplow-custom-events-for-monitor-apm-alerts.yml b/changelogs/unreleased/36955-snowplow-custom-events-for-monitor-apm-alerts.yml new file mode 100644 index 0000000000000000000000000000000000000000..0e362a171b2911c083ee2f53c4fdf2a3b80db206 --- /dev/null +++ b/changelogs/unreleased/36955-snowplow-custom-events-for-monitor-apm-alerts.yml @@ -0,0 +1,5 @@ +--- +title: Custom snowplow events for monitoring alerts +merge_request: 21963 +author: +type: added diff --git a/changelogs/unreleased/37238-add-ability-to-duplicate-the-common-metrics-dashboard.yml b/changelogs/unreleased/37238-add-ability-to-duplicate-the-common-metrics-dashboard.yml new file mode 100644 index 0000000000000000000000000000000000000000..5d175d3a1fc8aeccc3f3e06cad4deec394ea4fdb --- /dev/null +++ b/changelogs/unreleased/37238-add-ability-to-duplicate-the-common-metrics-dashboard.yml @@ -0,0 +1,5 @@ +--- +title: Add ability to duplicate the common metrics dashboard +merge_request: 21929 +author: +type: added diff --git a/changelogs/unreleased/37359-cablett-support-envelope-to.yml b/changelogs/unreleased/37359-cablett-support-envelope-to.yml new file mode 100644 index 0000000000000000000000000000000000000000..510ebe1c32d4f1c0e18751046f7ad737089adc42 --- /dev/null +++ b/changelogs/unreleased/37359-cablett-support-envelope-to.yml @@ -0,0 +1,5 @@ +--- +title: Accept `Envelope-To` as possible location for Service Desk key +merge_request: 22354 +author: Max Winterstein +type: added diff --git a/changelogs/unreleased/37449-fix-eks-authenticate-button.yml b/changelogs/unreleased/37449-fix-eks-authenticate-button.yml new file mode 100644 index 0000000000000000000000000000000000000000..e0871e7d7c62902b54e332b4ab7683fb2d63117f --- /dev/null +++ b/changelogs/unreleased/37449-fix-eks-authenticate-button.yml @@ -0,0 +1,5 @@ +--- +title: 'fix: EKS credentials form does not reset after error' +merge_request: 21958 +author: +type: other diff --git a/changelogs/unreleased/37682-fix-diff-file-creation.yml b/changelogs/unreleased/37682-fix-diff-file-creation.yml new file mode 100644 index 0000000000000000000000000000000000000000..fb9761dec981da2df6f0c15582fd9a7f57016570 --- /dev/null +++ b/changelogs/unreleased/37682-fix-diff-file-creation.yml @@ -0,0 +1,5 @@ +--- +title: Add fallbacks and proper errors for diff file creation +merge_request: 21034 +author: +type: fixed diff --git a/changelogs/unreleased/37725-test-reports-smart-list.yml b/changelogs/unreleased/37725-test-reports-smart-list.yml new file mode 100644 index 0000000000000000000000000000000000000000..48353e71e97e0fa60453227ad3a84ab5974428b0 --- /dev/null +++ b/changelogs/unreleased/37725-test-reports-smart-list.yml @@ -0,0 +1,5 @@ +--- +title: Added smart virtual list component to test reports to enhance rendering performance +merge_request: 22381 +author: +type: performance diff --git a/changelogs/unreleased/37963-document-var-default.yml b/changelogs/unreleased/37963-document-var-default.yml new file mode 100644 index 0000000000000000000000000000000000000000..df5c1c664809f4c82b21cab32887b772d5a6d1aa --- /dev/null +++ b/changelogs/unreleased/37963-document-var-default.yml @@ -0,0 +1,5 @@ +--- +title: Document MAVEN_CLI_OPTS defaults for maven project dependency scanning and update when the variable is used +merge_request: 22126 +author: +type: added diff --git a/changelogs/unreleased/38130-update-header-for-all-stacktrace-entries.yml b/changelogs/unreleased/38130-update-header-for-all-stacktrace-entries.yml new file mode 100644 index 0000000000000000000000000000000000000000..bdc7b6c02f543804f8df89d183a2624521206873 --- /dev/null +++ b/changelogs/unreleased/38130-update-header-for-all-stacktrace-entries.yml @@ -0,0 +1,5 @@ +--- +title: Display fn, line num and column in stacktrace entry caption +merge_request: 22905 +author: +type: added diff --git a/changelogs/unreleased/38223-add-gitlab-commit-path.yml b/changelogs/unreleased/38223-add-gitlab-commit-path.yml new file mode 100644 index 0000000000000000000000000000000000000000..34373efc0015a1d2c3e094bec8215dc6c0a14eb1 --- /dev/null +++ b/changelogs/unreleased/38223-add-gitlab-commit-path.yml @@ -0,0 +1,5 @@ +--- +title: Add gitlab_commit_path to Sentry Error Details Response +merge_request: 22803 +author: +type: added diff --git a/changelogs/unreleased/38223-link-to-gitlab-commit-in-error-detail-page-FE.yml b/changelogs/unreleased/38223-link-to-gitlab-commit-in-error-detail-page-FE.yml new file mode 100644 index 0000000000000000000000000000000000000000..8adae3d266db573e66ca9c44d60f1a3d36e06e25 --- /dev/null +++ b/changelogs/unreleased/38223-link-to-gitlab-commit-in-error-detail-page-FE.yml @@ -0,0 +1,5 @@ +--- +title: Link to GitLab commit in Sentry error details page +merge_request: 22431 +author: +type: added diff --git a/changelogs/unreleased/38223-link-to-gitlab-commit-in-error-detail-page.yml b/changelogs/unreleased/38223-link-to-gitlab-commit-in-error-detail-page.yml new file mode 100644 index 0000000000000000000000000000000000000000..6af34ca04487bc0a2a3eaa4b96e396f9e0372384 --- /dev/null +++ b/changelogs/unreleased/38223-link-to-gitlab-commit-in-error-detail-page.yml @@ -0,0 +1,5 @@ +--- +title: Add GitLab commit to error detail endpoint +merge_request: 22174 +author: +type: added diff --git a/changelogs/unreleased/38242-persisting-pipeline-config_content.yml b/changelogs/unreleased/38242-persisting-pipeline-config_content.yml new file mode 100644 index 0000000000000000000000000000000000000000..02c391186f96db3091dbbcdbacb5656504fbb3cc --- /dev/null +++ b/changelogs/unreleased/38242-persisting-pipeline-config_content.yml @@ -0,0 +1,5 @@ +--- +title: Implement saving config content for pipelines in a new table 'ci_pipelines_config' +merge_request: 21827 +author: +type: other diff --git a/changelogs/unreleased/38347-expose-labels-description-html.yml b/changelogs/unreleased/38347-expose-labels-description-html.yml new file mode 100644 index 0000000000000000000000000000000000000000..ea3972297e37b870d5096da1044008f401b2eafa --- /dev/null +++ b/changelogs/unreleased/38347-expose-labels-description-html.yml @@ -0,0 +1,5 @@ +--- +title: Expose description_html for labels +merge_request: 21413 +author: +type: changed diff --git a/changelogs/unreleased/38378-job-log-scroll-up-and-scroll-down-icons-are-inconsistent-2.yml b/changelogs/unreleased/38378-job-log-scroll-up-and-scroll-down-icons-are-inconsistent-2.yml new file mode 100644 index 0000000000000000000000000000000000000000..c4860b21563a97d63250a7ed8805893854568467 --- /dev/null +++ b/changelogs/unreleased/38378-job-log-scroll-up-and-scroll-down-icons-are-inconsistent-2.yml @@ -0,0 +1,5 @@ +--- +title: Fix CI job's scroll down icon and update animation +merge_request: 21442 +author: +type: fix diff --git a/changelogs/unreleased/39100-retry-failures-during-import.yml b/changelogs/unreleased/39100-retry-failures-during-import.yml new file mode 100644 index 0000000000000000000000000000000000000000..e8ffadd0ca990a877b5eb1e9f5c126703afa9561 --- /dev/null +++ b/changelogs/unreleased/39100-retry-failures-during-import.yml @@ -0,0 +1,5 @@ +--- +title: Add retry logic for failures during import +merge_request: 22265 +author: +type: added diff --git a/changelogs/unreleased/39113-project-user-grafana-integrations.yml b/changelogs/unreleased/39113-project-user-grafana-integrations.yml new file mode 100644 index 0000000000000000000000000000000000000000..9b0e470c0ed116c6116bf8470c001b81557dc90d --- /dev/null +++ b/changelogs/unreleased/39113-project-user-grafana-integrations.yml @@ -0,0 +1,5 @@ +--- +title: Preload project, user and group to reuse objects during project import +merge_request: 21853 +author: +type: performance diff --git a/changelogs/unreleased/39119-actioncontroller-urlgenerationerror-no-route-matches-action-evidenc.yml b/changelogs/unreleased/39119-actioncontroller-urlgenerationerror-no-route-matches-action-evidenc.yml new file mode 100644 index 0000000000000000000000000000000000000000..25d8ef6651b6f39a6ab22581914855e687d78cf3 --- /dev/null +++ b/changelogs/unreleased/39119-actioncontroller-urlgenerationerror-no-route-matches-action-evidenc.yml @@ -0,0 +1,5 @@ +--- +title: Fix releases page when tag contains a slash +merge_request: 22527 +author: +type: fixed diff --git a/changelogs/unreleased/39140-scope-modsec-feature-flag-to-groups.yml b/changelogs/unreleased/39140-scope-modsec-feature-flag-to-groups.yml new file mode 100644 index 0000000000000000000000000000000000000000..54b0a8ac6db724b05b5af013d61948b7ba241f84 --- /dev/null +++ b/changelogs/unreleased/39140-scope-modsec-feature-flag-to-groups.yml @@ -0,0 +1,5 @@ +--- +title: Add modsecurity_enabled setting to managed ingress +merge_request: 21968 +author: +type: added diff --git a/changelogs/unreleased/39140-toggle-modsecurity-enabled-for-managed-ingress.yml b/changelogs/unreleased/39140-toggle-modsecurity-enabled-for-managed-ingress.yml new file mode 100644 index 0000000000000000000000000000000000000000..3d4d5ca23f3ef8b1b8182e693638675caf88a17d --- /dev/null +++ b/changelogs/unreleased/39140-toggle-modsecurity-enabled-for-managed-ingress.yml @@ -0,0 +1,5 @@ +--- +title: Add enable_modsecurity setting to managed ingress +merge_request: 21966 +author: +type: added diff --git a/changelogs/unreleased/39491-slightly-misleading-tool-tip.yml b/changelogs/unreleased/39491-slightly-misleading-tool-tip.yml new file mode 100644 index 0000000000000000000000000000000000000000..6ce0d80318fce9e1e3236a1a61c611dc0778e51d --- /dev/null +++ b/changelogs/unreleased/39491-slightly-misleading-tool-tip.yml @@ -0,0 +1,5 @@ +--- +title: Update to clarify slightly misleading tool tip +merge_request: 22222 +author: +type: other diff --git a/changelogs/unreleased/39498-part-3.yml b/changelogs/unreleased/39498-part-3.yml new file mode 100644 index 0000000000000000000000000000000000000000..94475a2520c4ea366a29539c74d566358b2e8f49 --- /dev/null +++ b/changelogs/unreleased/39498-part-3.yml @@ -0,0 +1,5 @@ +--- +title: "!21542 Part 3: Handle edge cases in stage and unstage mutations" +merge_request: 21676 +author: +type: fixed diff --git a/changelogs/unreleased/39498-part-4.yml b/changelogs/unreleased/39498-part-4.yml new file mode 100644 index 0000000000000000000000000000000000000000..a12427c8fb3e32f513a36fc3ad8dfbb222980440 --- /dev/null +++ b/changelogs/unreleased/39498-part-4.yml @@ -0,0 +1,5 @@ +--- +title: "Web IDE: Fix Incorrect diff of deletion and addition of the same file" +merge_request: 21680 +author: +type: fixed diff --git a/changelogs/unreleased/39825-resolve-sentry-error-backend.yml b/changelogs/unreleased/39825-resolve-sentry-error-backend.yml new file mode 100644 index 0000000000000000000000000000000000000000..bb879088d767716cbf727436d2a680072200e070 --- /dev/null +++ b/changelogs/unreleased/39825-resolve-sentry-error-backend.yml @@ -0,0 +1,5 @@ +--- +title: Add internal API to update Sentry error status +merge_request: 22454 +author: +type: added diff --git a/changelogs/unreleased/39825-update-sentry-error-status-FE.yml b/changelogs/unreleased/39825-update-sentry-error-status-FE.yml new file mode 100644 index 0000000000000000000000000000000000000000..4ffe4424717af974b2019276e6595e1408e01b7a --- /dev/null +++ b/changelogs/unreleased/39825-update-sentry-error-status-FE.yml @@ -0,0 +1,5 @@ +--- +title: Add ability to ignore/resolve errors from error tracking detail page +merge_request: 22475 +author: +type: added diff --git a/changelogs/unreleased/39951-fix-dependency-scanning-regex.yml b/changelogs/unreleased/39951-fix-dependency-scanning-regex.yml new file mode 100644 index 0000000000000000000000000000000000000000..8866b31753c1040ebf435139deedd41f1af4339a --- /dev/null +++ b/changelogs/unreleased/39951-fix-dependency-scanning-regex.yml @@ -0,0 +1,5 @@ +--- +title: Fix regex matching for gemnasium dependency scanning jobs +merge_request: 22025 +author: Maximilian Stendler +type: fix diff --git a/changelogs/unreleased/39979-grapql-for-error-details.yml b/changelogs/unreleased/39979-grapql-for-error-details.yml new file mode 100644 index 0000000000000000000000000000000000000000..6ae3f203f56098c2f1dc00c5d2d8f9be0db1a16e --- /dev/null +++ b/changelogs/unreleased/39979-grapql-for-error-details.yml @@ -0,0 +1,5 @@ +--- +title: Use GraphQL to load error tracking detail page content +merge_request: 22422 +author: +type: performance diff --git a/changelogs/unreleased/43517-no-changes.yml b/changelogs/unreleased/43517-no-changes.yml new file mode 100644 index 0000000000000000000000000000000000000000..e36e0688251b2f41de5c8876e6356ede7fbe81fa --- /dev/null +++ b/changelogs/unreleased/43517-no-changes.yml @@ -0,0 +1,6 @@ +--- +title: Fix "No changes" empty state showing up in changes tab, despite there being + changes +merge_request: 21713 +author: +type: fixed diff --git a/changelogs/unreleased/4913-frontend-outdated-security-report.yml b/changelogs/unreleased/4913-frontend-outdated-security-report.yml new file mode 100644 index 0000000000000000000000000000000000000000..b1144f621f3d98b12840e2fbd55b784e551c0e1d --- /dev/null +++ b/changelogs/unreleased/4913-frontend-outdated-security-report.yml @@ -0,0 +1,5 @@ +--- +title: Display in MR if security report is outdated +merge_request: 20954 +author: +type: other diff --git a/changelogs/unreleased/55347-mr-diffs-file-count-increments-while-batch-loading.yml b/changelogs/unreleased/55347-mr-diffs-file-count-increments-while-batch-loading.yml new file mode 100644 index 0000000000000000000000000000000000000000..3058624a4d822655b284434f261c9d470ed6843f --- /dev/null +++ b/changelogs/unreleased/55347-mr-diffs-file-count-increments-while-batch-loading.yml @@ -0,0 +1,5 @@ +--- +title: Fix MR diffs file count increments while batch loading +merge_request: 21764 +author: +type: fixed diff --git a/changelogs/unreleased/7132-document-go-support.yml b/changelogs/unreleased/7132-document-go-support.yml new file mode 100644 index 0000000000000000000000000000000000000000..e6b61300bf9e380fa149506e37e5eceae0cebefc --- /dev/null +++ b/changelogs/unreleased/7132-document-go-support.yml @@ -0,0 +1,5 @@ +--- +title: Document go support for dependency scanning +merge_request: 22806 +author: +type: added diff --git a/changelogs/unreleased/7132-go-support-for-dep-scanning.yml b/changelogs/unreleased/7132-go-support-for-dep-scanning.yml new file mode 100644 index 0000000000000000000000000000000000000000..59fe71cb4aa66a8d296822cf6576e58a3804f332 --- /dev/null +++ b/changelogs/unreleased/7132-go-support-for-dep-scanning.yml @@ -0,0 +1,5 @@ +--- +title: Detect go when doing dependency scanning +merge_request: 22712 +author: +type: added diff --git a/changelogs/unreleased/7225-no-audit-event-for-adding-a-user-to-a-group-or-project-through-api.yml b/changelogs/unreleased/7225-no-audit-event-for-adding-a-user-to-a-group-or-project-through-api.yml new file mode 100644 index 0000000000000000000000000000000000000000..c139cea868a2fca3f0742479ce81d996280a7bb0 --- /dev/null +++ b/changelogs/unreleased/7225-no-audit-event-for-adding-a-user-to-a-group-or-project-through-api.yml @@ -0,0 +1,5 @@ +--- +title: Add audit events to the adding members to project or group API endpoint +merge_request: 21633 +author: +type: changed diff --git a/changelogs/unreleased/9215-promote-issue-to-epic-may-expose-confidential-information-warning-ne.yml b/changelogs/unreleased/9215-promote-issue-to-epic-may-expose-confidential-information-warning-ne.yml new file mode 100644 index 0000000000000000000000000000000000000000..08c18aeafa2e35a86710bff04799a6e432c3a586 --- /dev/null +++ b/changelogs/unreleased/9215-promote-issue-to-epic-may-expose-confidential-information-warning-ne.yml @@ -0,0 +1,5 @@ +--- +title: Improve warning for Promote issue to epic +merge_request: 21158 +author: +type: changed diff --git a/changelogs/unreleased/I-118638.yml b/changelogs/unreleased/I-118638.yml new file mode 100644 index 0000000000000000000000000000000000000000..6d8e0c1e17b4aeadddf5d80f3b5ba93749079b8d --- /dev/null +++ b/changelogs/unreleased/I-118638.yml @@ -0,0 +1,5 @@ +--- +title: fix CSS when board issue is collapsed +merge_request: 21940 +author: allenlai18 +type: fixed diff --git a/changelogs/unreleased/ab-projects-api-created-at-indexes.yml b/changelogs/unreleased/ab-projects-api-created-at-indexes.yml new file mode 100644 index 0000000000000000000000000000000000000000..8948106db085d2d344f94c49635321489b2eb746 --- /dev/null +++ b/changelogs/unreleased/ab-projects-api-created-at-indexes.yml @@ -0,0 +1,5 @@ +--- +title: Create optimal indexes for created_at order (Projects API) +merge_request: 22623 +author: +type: performance diff --git a/changelogs/unreleased/ab-projects-api-indexes-authenticated-calls.yml b/changelogs/unreleased/ab-projects-api-indexes-authenticated-calls.yml new file mode 100644 index 0000000000000000000000000000000000000000..1bfa3b87a885913b4585849c89d4c34dbf828c8d --- /dev/null +++ b/changelogs/unreleased/ab-projects-api-indexes-authenticated-calls.yml @@ -0,0 +1,5 @@ +--- +title: Add indexes for authenticated Project API calls +merge_request: 22886 +author: +type: performance diff --git a/changelogs/unreleased/ab-projects-api-more-indexes.yml b/changelogs/unreleased/ab-projects-api-more-indexes.yml new file mode 100644 index 0000000000000000000000000000000000000000..1567e78cba33f24f6acfb9bd048ac3067922855f --- /dev/null +++ b/changelogs/unreleased/ab-projects-api-more-indexes.yml @@ -0,0 +1,5 @@ +--- +title: Add more indexes for other order_by options (Projects API) +merge_request: 22784 +author: +type: performance diff --git a/changelogs/unreleased/acme-order-short-expiration.yml b/changelogs/unreleased/acme-order-short-expiration.yml new file mode 100644 index 0000000000000000000000000000000000000000..d2246cfa88a844638f0f9146ccadb50b19624bd4 --- /dev/null +++ b/changelogs/unreleased/acme-order-short-expiration.yml @@ -0,0 +1,5 @@ +--- +title: Retry obtaining Let's Encrypt certificates every 2 hours if it wasn't successful +merge_request: 22336 +author: +type: fixed diff --git a/changelogs/unreleased/add-db-timings-to-sidekiq-logs.yml b/changelogs/unreleased/add-db-timings-to-sidekiq-logs.yml new file mode 100644 index 0000000000000000000000000000000000000000..8abb22a1d74eefd68427517b61cae5e83b70622b --- /dev/null +++ b/changelogs/unreleased/add-db-timings-to-sidekiq-logs.yml @@ -0,0 +1,5 @@ +--- +title: Log database time in Sidekiq JSON logs +merge_request: 22548 +author: +type: other diff --git a/changelogs/unreleased/add-geo-node-api.yml b/changelogs/unreleased/add-geo-node-api.yml new file mode 100644 index 0000000000000000000000000000000000000000..9db482531cce2fd726c13d029af8d504a432034e --- /dev/null +++ b/changelogs/unreleased/add-geo-node-api.yml @@ -0,0 +1,5 @@ +--- +title: Add API endpoint for creating a Geo node +merge_request: 22392 +author: Rajendra Kadam +type: added diff --git a/changelogs/unreleased/add-get-date-in-future-util.yml b/changelogs/unreleased/add-get-date-in-future-util.yml new file mode 100644 index 0000000000000000000000000000000000000000..c9520c2416e85422df4317f8ed4877bf94d93014 --- /dev/null +++ b/changelogs/unreleased/add-get-date-in-future-util.yml @@ -0,0 +1,5 @@ +--- +title: Add getDateInFuture util method +merge_request: 22671 +author: +type: added diff --git a/changelogs/unreleased/add-group-id-column.yml b/changelogs/unreleased/add-group-id-column.yml new file mode 100644 index 0000000000000000000000000000000000000000..dab953b372e76ed354dab96b8702a96a77b98ba1 --- /dev/null +++ b/changelogs/unreleased/add-group-id-column.yml @@ -0,0 +1,5 @@ +--- +title: Save Instance Administrators group ID in DB +merge_request: 22600 +author: +type: changed diff --git a/changelogs/unreleased/add-warnings-to-sidekiq-rake-tasks.yml b/changelogs/unreleased/add-warnings-to-sidekiq-rake-tasks.yml new file mode 100644 index 0000000000000000000000000000000000000000..a23819786f6fe55f44d6bf0d26d9f792e833c960 --- /dev/null +++ b/changelogs/unreleased/add-warnings-to-sidekiq-rake-tasks.yml @@ -0,0 +1,5 @@ +--- +title: Add deprecation warning to Rake tasks in sidekiq namespace +merge_request: +author: +type: removed diff --git a/changelogs/unreleased/add_comment_on_event_enabled_to_services_api.yml b/changelogs/unreleased/add_comment_on_event_enabled_to_services_api.yml new file mode 100644 index 0000000000000000000000000000000000000000..77acad7af37c3bfddb718b34747cf96d9b25017c --- /dev/null +++ b/changelogs/unreleased/add_comment_on_event_enabled_to_services_api.yml @@ -0,0 +1,5 @@ +--- +title: Add comment_on_event_enabled to services API +merge_request: +author: +type: added diff --git a/changelogs/unreleased/ag-add-app-multi-logger.yml b/changelogs/unreleased/ag-add-app-multi-logger.yml new file mode 100644 index 0000000000000000000000000000000000000000..739861ab1ee4bedf3b0856a470555d082ce99076 --- /dev/null +++ b/changelogs/unreleased/ag-add-app-multi-logger.yml @@ -0,0 +1,5 @@ +--- +title: Add structured logging for application logs +merge_request: 22379 +author: +type: other diff --git a/changelogs/unreleased/ak-bubble-up-log-source.yml b/changelogs/unreleased/ak-bubble-up-log-source.yml new file mode 100644 index 0000000000000000000000000000000000000000..8bdd58483c72273fac70afb6a35d8890a8722eef --- /dev/null +++ b/changelogs/unreleased/ak-bubble-up-log-source.yml @@ -0,0 +1,5 @@ +--- +title: Pass log source to the frontend +merge_request: 22694 +author: +type: changed diff --git a/changelogs/unreleased/ak-logs-search-4.yml b/changelogs/unreleased/ak-logs-search-4.yml new file mode 100644 index 0000000000000000000000000000000000000000..b26db8e5142cd1d05eb1f32c3320669b56e3fc3d --- /dev/null +++ b/changelogs/unreleased/ak-logs-search-4.yml @@ -0,0 +1,5 @@ +--- +title: Add full text search to pod logs +merge_request: 21656 +author: +type: added diff --git a/changelogs/unreleased/alai-37360-sidebarBug.yml b/changelogs/unreleased/alai-37360-sidebarBug.yml new file mode 100644 index 0000000000000000000000000000000000000000..798b334f3256a5bfe397f08c03a52645b79eadb5 --- /dev/null +++ b/changelogs/unreleased/alai-37360-sidebarBug.yml @@ -0,0 +1,5 @@ +--- +title: Sidebar getting partially hidden behind the content block +merge_request: 21978 +author: allenlai18 +type: fixed diff --git a/changelogs/unreleased/allow-skip-ci-on-rebase.yml b/changelogs/unreleased/allow-skip-ci-on-rebase.yml new file mode 100644 index 0000000000000000000000000000000000000000..2388aee0522b5f19050d26f907deb18238198c8f --- /dev/null +++ b/changelogs/unreleased/allow-skip-ci-on-rebase.yml @@ -0,0 +1,5 @@ +--- +title: Allow "skip_ci" flag to be passed to rebase operation +merge_request: 22800 +author: +type: added diff --git a/changelogs/unreleased/ap-33785-file-integrity.yml b/changelogs/unreleased/ap-33785-file-integrity.yml new file mode 100644 index 0000000000000000000000000000000000000000..36ff617a252293a9bc94aaf50885e72c863f3b4f --- /dev/null +++ b/changelogs/unreleased/ap-33785-file-integrity.yml @@ -0,0 +1,5 @@ +--- +title: Ensure content matches extension on image uploads +merge_request: 20697 +author: +type: security diff --git a/changelogs/unreleased/api_services_index_endpoint.yml b/changelogs/unreleased/api_services_index_endpoint.yml new file mode 100644 index 0000000000000000000000000000000000000000..4cf45c2331a6e13b2f51824d67dfd40e1d26c1e1 --- /dev/null +++ b/changelogs/unreleased/api_services_index_endpoint.yml @@ -0,0 +1,5 @@ +--- +title: New API endpoint GET /projects/:id/services +merge_request: 21330 +author: +type: added diff --git a/changelogs/unreleased/backward-compatibility-for-background-migrations.yml b/changelogs/unreleased/backward-compatibility-for-background-migrations.yml new file mode 100644 index 0000000000000000000000000000000000000000..c3a62fafd3fd15e541daecc47e59747c9f675de2 --- /dev/null +++ b/changelogs/unreleased/backward-compatibility-for-background-migrations.yml @@ -0,0 +1,5 @@ +--- +title: Make BackgroundMigrationWorker backward compatible +merge_request: 22271 +author: +type: fixed diff --git a/changelogs/unreleased/better-errors-from-extends.yml b/changelogs/unreleased/better-errors-from-extends.yml new file mode 100644 index 0000000000000000000000000000000000000000..5eede9b2d6b04c0bbb1a0e06fa40ac5394eb6013 --- /dev/null +++ b/changelogs/unreleased/better-errors-from-extends.yml @@ -0,0 +1,5 @@ +--- +title: Add JSON error context to extends error in CI lint +merge_request: 30066 +author: +type: changed diff --git a/changelogs/unreleased/burndown-optimisations.yml b/changelogs/unreleased/burndown-optimisations.yml new file mode 100644 index 0000000000000000000000000000000000000000..07b6375c47733f1cc4a9bd48fd207afc82b5d0a6 --- /dev/null +++ b/changelogs/unreleased/burndown-optimisations.yml @@ -0,0 +1,5 @@ +--- +title: Performance improvements on milestone burndown chart +merge_request: 22380 +author: +type: performance diff --git a/changelogs/unreleased/bvl-fix-mwps-api.yml b/changelogs/unreleased/bvl-fix-mwps-api.yml new file mode 100644 index 0000000000000000000000000000000000000000..e5f26aa17bad077e49cc3d24ac6f9e0d2d6dc914 --- /dev/null +++ b/changelogs/unreleased/bvl-fix-mwps-api.yml @@ -0,0 +1,6 @@ +--- +title: Merge a merge request immediately when passing merge when pipeline succeeds + to the merge API when the head pipeline already succeeded +merge_request: 22777 +author: +type: fixed diff --git a/changelogs/unreleased/bvl-gitaly-dynamic-deadline.yml b/changelogs/unreleased/bvl-gitaly-dynamic-deadline.yml new file mode 100644 index 0000000000000000000000000000000000000000..e44127ccf60b455ec299f291dff48b8686714c73 --- /dev/null +++ b/changelogs/unreleased/bvl-gitaly-dynamic-deadline.yml @@ -0,0 +1,5 @@ +--- +title: Don't let Gitaly calls exceed a request time of 55 seconds +merge_request: 21492 +author: +type: performance diff --git a/changelogs/unreleased/bw-board-sorting.yml b/changelogs/unreleased/bw-board-sorting.yml new file mode 100644 index 0000000000000000000000000000000000000000..3eaabe67aa983026fa887890864f5ed9b98ba48f --- /dev/null +++ b/changelogs/unreleased/bw-board-sorting.yml @@ -0,0 +1,5 @@ +--- +title: Project issue board names now sorted correctly in FOSS +merge_request: 22807 +author: +type: fixed diff --git a/changelogs/unreleased/cache-ref-names-in-discussion-endpoint.yml b/changelogs/unreleased/cache-ref-names-in-discussion-endpoint.yml new file mode 100644 index 0000000000000000000000000000000000000000..f0766dd4538401962b2e4423968a7a40b74f5902 --- /dev/null +++ b/changelogs/unreleased/cache-ref-names-in-discussion-endpoint.yml @@ -0,0 +1,5 @@ +--- +title: Reduce Gitaly calls needed for issue discussions +merge_request: +author: +type: performance diff --git a/changelogs/unreleased/cancel-merge-train-pipeline-when-dropped.yml b/changelogs/unreleased/cancel-merge-train-pipeline-when-dropped.yml new file mode 100644 index 0000000000000000000000000000000000000000..1b878ccc58eb908428abf2173700c96f521e899c --- /dev/null +++ b/changelogs/unreleased/cancel-merge-train-pipeline-when-dropped.yml @@ -0,0 +1,5 @@ +--- +title: Cancel running pipelines when merge request is dropped from merge train +merge_request: 22146 +author: +type: fixed diff --git a/changelogs/unreleased/ci-resource-group-doc.yml b/changelogs/unreleased/ci-resource-group-doc.yml new file mode 100644 index 0000000000000000000000000000000000000000..a675e5f29713334ae64b7f6c9eb3c54f4e03151e --- /dev/null +++ b/changelogs/unreleased/ci-resource-group-doc.yml @@ -0,0 +1,6 @@ +--- +title: Add 'resource_group' keyword to .gitlab-ci.yml for pipeline job concurrency + limitation +merge_request: 21617 +author: +type: added diff --git a/changelogs/unreleased/ci-resource-group-model.yml b/changelogs/unreleased/ci-resource-group-model.yml new file mode 100644 index 0000000000000000000000000000000000000000..98bc0159626d302122a0862a538b37e66eb10148 --- /dev/null +++ b/changelogs/unreleased/ci-resource-group-model.yml @@ -0,0 +1,5 @@ +--- +title: Add Ci Resource Group models +merge_request: 20950 +author: +type: other diff --git a/changelogs/unreleased/cluster-applications-0-4-0.yml b/changelogs/unreleased/cluster-applications-0-4-0.yml new file mode 100644 index 0000000000000000000000000000000000000000..0a06107bf62ba1442d6f1c63bf443c58fe85d8d5 --- /dev/null +++ b/changelogs/unreleased/cluster-applications-0-4-0.yml @@ -0,0 +1,5 @@ +--- +title: Bump cluster-applications image to v0.4.0, adding support to install cert-manager +merge_request: 22657 +author: +type: changed diff --git a/changelogs/unreleased/create-downstream-pipeline-in-same-project.yml b/changelogs/unreleased/create-downstream-pipeline-in-same-project.yml new file mode 100644 index 0000000000000000000000000000000000000000..ccba831abfe1e4909a80ee903d84e71db24eaa4b --- /dev/null +++ b/changelogs/unreleased/create-downstream-pipeline-in-same-project.yml @@ -0,0 +1,5 @@ +--- +title: Allow an upstream pipeline to create a downstream pipeline in the same project +merge_request: 22663 +author: +type: added diff --git a/changelogs/unreleased/dast_mr_reports_feature_flag.yml b/changelogs/unreleased/dast_mr_reports_feature_flag.yml new file mode 100644 index 0000000000000000000000000000000000000000..21ff19cc1bfc5c243d0756cdb37b92cb3d529c54 --- /dev/null +++ b/changelogs/unreleased/dast_mr_reports_feature_flag.yml @@ -0,0 +1,5 @@ +--- +title: Turns on backend MR reports for DAST by default +merge_request: 22001 +author: +type: changed diff --git a/changelogs/unreleased/dblessing_update_net_ldap_gem.yml b/changelogs/unreleased/dblessing_update_net_ldap_gem.yml new file mode 100644 index 0000000000000000000000000000000000000000..52cced0ef6c51f44190ed2a4a5eca92ab445929b --- /dev/null +++ b/changelogs/unreleased/dblessing_update_net_ldap_gem.yml @@ -0,0 +1,5 @@ +--- +title: Update the Net-LDAP gem to 0.16.2 +merge_request: +author: +type: other diff --git a/changelogs/unreleased/default-epic_new_issue-ff.yml b/changelogs/unreleased/default-epic_new_issue-ff.yml new file mode 100644 index 0000000000000000000000000000000000000000..ea6ed8e479a9f2e3a4b9e6d2978bbeb80296aaca --- /dev/null +++ b/changelogs/unreleased/default-epic_new_issue-ff.yml @@ -0,0 +1,5 @@ +--- +title: Add ability to create an issue in an epic +merge_request: 22833 +author: +type: added diff --git a/changelogs/unreleased/dennis-enable-code-review-analytics-by-default.yml b/changelogs/unreleased/dennis-enable-code-review-analytics-by-default.yml new file mode 100644 index 0000000000000000000000000000000000000000..f28f0989c5501e6f6cd587457bf6e494da1b677e --- /dev/null +++ b/changelogs/unreleased/dennis-enable-code-review-analytics-by-default.yml @@ -0,0 +1,5 @@ +--- +title: Enable Code Review Analytics by default +merge_request: 23285 +author: +type: changed diff --git a/changelogs/unreleased/deployment-validate-sha.yml b/changelogs/unreleased/deployment-validate-sha.yml new file mode 100644 index 0000000000000000000000000000000000000000..93e6faf1313df6c22a39afd19f9a0ddda2cb93ea --- /dev/null +++ b/changelogs/unreleased/deployment-validate-sha.yml @@ -0,0 +1,5 @@ +--- +title: Validate deployment SHAs and refs +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/deployments-api-filters.yml b/changelogs/unreleased/deployments-api-filters.yml new file mode 100644 index 0000000000000000000000000000000000000000..274a5483ff2a61e003235f934d6c41a94b0deb9b --- /dev/null +++ b/changelogs/unreleased/deployments-api-filters.yml @@ -0,0 +1,5 @@ +--- +title: Filter deployments using the environment & status +merge_request: 22996 +author: +type: added diff --git a/changelogs/unreleased/display-rules-without-approvers.yml b/changelogs/unreleased/display-rules-without-approvers.yml new file mode 100644 index 0000000000000000000000000000000000000000..86bc7c2711cc99435823bb5f622d6e0164ffd9e0 --- /dev/null +++ b/changelogs/unreleased/display-rules-without-approvers.yml @@ -0,0 +1,5 @@ +--- +title: Show regular rules without approvers +merge_request: 21918 +author: +type: fixed diff --git a/changelogs/unreleased/djensen-explain-programming-languages-chart.yml b/changelogs/unreleased/djensen-explain-programming-languages-chart.yml new file mode 100644 index 0000000000000000000000000000000000000000..68883fc6d6fb105c66481ef9adb112018cf8372d --- /dev/null +++ b/changelogs/unreleased/djensen-explain-programming-languages-chart.yml @@ -0,0 +1,5 @@ +--- +title: Add measurement details for programming languages graph +merge_request: 20592 +author: +type: changed diff --git a/changelogs/unreleased/dont_run_auto_devops.yml b/changelogs/unreleased/dont_run_auto_devops.yml new file mode 100644 index 0000000000000000000000000000000000000000..eb460e0538bf662073c3798657b218cbd9260fb1 --- /dev/null +++ b/changelogs/unreleased/dont_run_auto_devops.yml @@ -0,0 +1,5 @@ +--- +title: Don't run Auto DevOps when no dockerfile or matching buildpack exists +merge_request: 20267 +author: +type: changed diff --git a/changelogs/unreleased/dz-move-mr-routes-2.yml b/changelogs/unreleased/dz-move-mr-routes-2.yml new file mode 100644 index 0000000000000000000000000000000000000000..60a77149ab1e0ce3bd58bee90d22c1d455f04fa3 --- /dev/null +++ b/changelogs/unreleased/dz-move-mr-routes-2.yml @@ -0,0 +1,5 @@ +--- +title: Copy merge request routes to the - scope +merge_request: 22082 +author: +type: changed diff --git a/changelogs/unreleased/dz-move-repo-routes-2.yml b/changelogs/unreleased/dz-move-repo-routes-2.yml new file mode 100644 index 0000000000000000000000000000000000000000..349c2a9f4800a78e257c46be47c2f98b811ac956 --- /dev/null +++ b/changelogs/unreleased/dz-move-repo-routes-2.yml @@ -0,0 +1,5 @@ +--- +title: Copy repository route under - scope +merge_request: 22092 +author: +type: changed diff --git a/changelogs/unreleased/dz-rename-plugins.yml b/changelogs/unreleased/dz-rename-plugins.yml new file mode 100644 index 0000000000000000000000000000000000000000..024897d3d659bbe961a32f2497dd90ede83ea397 --- /dev/null +++ b/changelogs/unreleased/dz-rename-plugins.yml @@ -0,0 +1,5 @@ +--- +title: Rename GitLab Plugins feature to GitLab File Hooks +merge_request: 22979 +author: +type: changed diff --git a/changelogs/unreleased/eks-kubernetes-version-helptext.yml b/changelogs/unreleased/eks-kubernetes-version-helptext.yml new file mode 100644 index 0000000000000000000000000000000000000000..1d80700c97034914ec152679d96bf0a44ebb1aad --- /dev/null +++ b/changelogs/unreleased/eks-kubernetes-version-helptext.yml @@ -0,0 +1,5 @@ +--- +title: Removes incorrect help text from EKS Kubernetes version field +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/eks-role-name-helptext.yml b/changelogs/unreleased/eks-role-name-helptext.yml new file mode 100644 index 0000000000000000000000000000000000000000..20d192dbe8b7857f6fadfbf5439946aad670034e --- /dev/null +++ b/changelogs/unreleased/eks-role-name-helptext.yml @@ -0,0 +1,5 @@ +--- +title: Updates AWS EKS service role name help text to clarify it is distinct from provision role +merge_request: +author: +type: changed diff --git a/changelogs/unreleased/email-on-push-select-branches.yml b/changelogs/unreleased/email-on-push-select-branches.yml new file mode 100644 index 0000000000000000000000000000000000000000..d28bb33e3278a5e883d1ae0d26eba173d9b10ade --- /dev/null +++ b/changelogs/unreleased/email-on-push-select-branches.yml @@ -0,0 +1,5 @@ +--- +title: Add option to configure branches for which to send emails on push +merge_request: 22196 +author: +type: added diff --git a/changelogs/unreleased/embed-snippet-font.yml b/changelogs/unreleased/embed-snippet-font.yml new file mode 100644 index 0000000000000000000000000000000000000000..b0ae8b65f4c31b4629b2f4fbd1bdd64ccd7c5e4f --- /dev/null +++ b/changelogs/unreleased/embed-snippet-font.yml @@ -0,0 +1,5 @@ +--- +title: Align embedded snippet mono space font with GitLab mono space font. +merge_request: !20865 +author: +type: changed diff --git a/changelogs/unreleased/embed-snippet-ui-polish.yml b/changelogs/unreleased/embed-snippet-ui-polish.yml new file mode 100644 index 0000000000000000000000000000000000000000..a0836f5f443c04ec15685d22b389b09d49007375 --- /dev/null +++ b/changelogs/unreleased/embed-snippet-ui-polish.yml @@ -0,0 +1,5 @@ +--- +title: Fix embedded snippets UI polish issues +merge_request: !22637 +author: +type: changed diff --git a/changelogs/unreleased/enable-cross-project-artifacts.yml b/changelogs/unreleased/enable-cross-project-artifacts.yml new file mode 100644 index 0000000000000000000000000000000000000000..06db7cd034cc9a499fbc3093c530862eb627f5fe --- /dev/null +++ b/changelogs/unreleased/enable-cross-project-artifacts.yml @@ -0,0 +1,5 @@ +--- +title: Download cross-project artifacts by using needs keyword in the CI file +merge_request: 22161 +author: +type: added diff --git a/changelogs/unreleased/enable-parent-child-pipelines.yml b/changelogs/unreleased/enable-parent-child-pipelines.yml new file mode 100644 index 0000000000000000000000000000000000000000..aa38b06839a6e6ff82af0b53e8f6f20bc7e69ff7 --- /dev/null +++ b/changelogs/unreleased/enable-parent-child-pipelines.yml @@ -0,0 +1,5 @@ +--- +title: Allow a pipeline (parent) to create a child pipeline as downstream pipeline within the same project +merge_request: 21830 +author: +type: added diff --git a/changelogs/unreleased/environment-name-search-graphql.yml b/changelogs/unreleased/environment-name-search-graphql.yml new file mode 100644 index 0000000000000000000000000000000000000000..898cd7e91d6de9d716daa7c4bbddbe08ac959ae4 --- /dev/null +++ b/changelogs/unreleased/environment-name-search-graphql.yml @@ -0,0 +1,5 @@ +--- +title: Get Project's environment names via GraphQL +merge_request: 22932 +author: +type: added diff --git a/changelogs/unreleased/error-tracking-api.yml b/changelogs/unreleased/error-tracking-api.yml new file mode 100644 index 0000000000000000000000000000000000000000..7face12f23dad9202fdab691b1daf7e6333e803a --- /dev/null +++ b/changelogs/unreleased/error-tracking-api.yml @@ -0,0 +1,5 @@ +--- +title: Add API for getting sentry error tracking settings of a project +merge_request: 21788 +author: raju249 +type: added diff --git a/changelogs/unreleased/expose-tiller-log.yml b/changelogs/unreleased/expose-tiller-log.yml new file mode 100644 index 0000000000000000000000000000000000000000..71538f0533373e78ba6b4fbd1a5fa387d9adbab4 --- /dev/null +++ b/changelogs/unreleased/expose-tiller-log.yml @@ -0,0 +1,5 @@ +--- +title: Exposes tiller.log as artifact in Managed-Cluster-Applications GitLab CI template +merge_request: 22940 +author: +type: changed diff --git a/changelogs/unreleased/fe-ide-clean-up-discard-duplication.yml b/changelogs/unreleased/fe-ide-clean-up-discard-duplication.yml new file mode 100644 index 0000000000000000000000000000000000000000..ac02673a8eadcd8b06baee086fbf48397a59aaac --- /dev/null +++ b/changelogs/unreleased/fe-ide-clean-up-discard-duplication.yml @@ -0,0 +1,5 @@ +--- +title: Fix discard all to behave like discard single file in Web IDE +merge_request: 22572 +author: +type: fixed diff --git a/changelogs/unreleased/feat-appearance-api.yml b/changelogs/unreleased/feat-appearance-api.yml new file mode 100644 index 0000000000000000000000000000000000000000..916c1474fbdac2bd31ac8107b8dad88e16c01cb0 --- /dev/null +++ b/changelogs/unreleased/feat-appearance-api.yml @@ -0,0 +1,5 @@ +--- +title: Implement application appearance API endpoint +merge_request: 20674 +author: Fabio Huser +type: added diff --git a/changelogs/unreleased/feat-customizable-suggestion-commit-messages.yml b/changelogs/unreleased/feat-customizable-suggestion-commit-messages.yml new file mode 100644 index 0000000000000000000000000000000000000000..9fbff4118c8ef8a4ac820a25286b1d4e30562f6b --- /dev/null +++ b/changelogs/unreleased/feat-customizable-suggestion-commit-messages.yml @@ -0,0 +1,5 @@ +--- +title: Implement customizable commit messages for applied suggested changes +merge_request: 21411 +author: Fabio Huser +type: added diff --git a/changelogs/unreleased/feat-disable-issue-autoclose-feature.yml b/changelogs/unreleased/feat-disable-issue-autoclose-feature.yml new file mode 100644 index 0000000000000000000000000000000000000000..02b0f820baddd67765ca2c9132258225398b2a24 --- /dev/null +++ b/changelogs/unreleased/feat-disable-issue-autoclose-feature.yml @@ -0,0 +1,5 @@ +--- +title: Add capability to disable issue auto-close feature per project +merge_request: 21704 +author: Fabio Huser +type: added diff --git a/changelogs/unreleased/feat-pipeline-ui-deletion.yml b/changelogs/unreleased/feat-pipeline-ui-deletion.yml new file mode 100644 index 0000000000000000000000000000000000000000..630b0057fa9a71621482904a42cd0a4a3f16f415 --- /dev/null +++ b/changelogs/unreleased/feat-pipeline-ui-deletion.yml @@ -0,0 +1,5 @@ +--- +title: Add pipeline deletion button to pipeline details page +merge_request: 21365 +author: Fabio Huser +type: added diff --git a/changelogs/unreleased/feat-rust-cargo-toml-blob-view.yml b/changelogs/unreleased/feat-rust-cargo-toml-blob-view.yml new file mode 100644 index 0000000000000000000000000000000000000000..ccc5ea0fe7ebdf6cc07e40dc086a8195f106561e --- /dev/null +++ b/changelogs/unreleased/feat-rust-cargo-toml-blob-view.yml @@ -0,0 +1,5 @@ +--- +title: Add support for Rust Cargo.toml dependency vizualisation and linking +merge_request: 21374 +author: Fabio Huser +type: added diff --git a/changelogs/unreleased/feat-ssh-sha256-migration.yml b/changelogs/unreleased/feat-ssh-sha256-migration.yml new file mode 100644 index 0000000000000000000000000000000000000000..389247613f519928842ffd85ac6c0b424006e3b9 --- /dev/null +++ b/changelogs/unreleased/feat-ssh-sha256-migration.yml @@ -0,0 +1,5 @@ +--- +title: add background migration for sha256 fingerprints of ssh keys +merge_request: 21579 +author: Roger Meier +type: added diff --git a/changelogs/unreleased/feat-ssh-sha256-other-key.yml b/changelogs/unreleased/feat-ssh-sha256-other-key.yml new file mode 100644 index 0000000000000000000000000000000000000000..3fd5a2930d8dc70fe28c90251a32132e2b8541c2 --- /dev/null +++ b/changelogs/unreleased/feat-ssh-sha256-other-key.yml @@ -0,0 +1,5 @@ +--- +title: Display SHA fingerprint for Deploy Keys and extend api to query those +merge_request: 22665 +author: Roger Meier <r.meier@siemens.com> +type: added diff --git a/changelogs/unreleased/feature-split-diff-attempt-3.yml b/changelogs/unreleased/feature-split-diff-attempt-3.yml new file mode 100644 index 0000000000000000000000000000000000000000..8adda8be3afbeb2abe50b5bf9ebfc681c1461ea7 --- /dev/null +++ b/changelogs/unreleased/feature-split-diff-attempt-3.yml @@ -0,0 +1,5 @@ +--- +title: Load MR diff types lazily to reduce initial diff payload size +merge_request: 19930 +author: +type: added diff --git a/changelogs/unreleased/ff-toggle-backend.yml b/changelogs/unreleased/ff-toggle-backend.yml new file mode 100644 index 0000000000000000000000000000000000000000..cff969efda89d346e53c0f7c49f86550c3e5c2b5 --- /dev/null +++ b/changelogs/unreleased/ff-toggle-backend.yml @@ -0,0 +1,5 @@ +--- +title: Add feature flag override toggle +merge_request: 21598 +author: +type: added diff --git a/changelogs/unreleased/fix-container-scanning-remediation-bug.yml b/changelogs/unreleased/fix-container-scanning-remediation-bug.yml new file mode 100644 index 0000000000000000000000000000000000000000..07b80716ff36ac71d633ff63a786b379ff9027d3 --- /dev/null +++ b/changelogs/unreleased/fix-container-scanning-remediation-bug.yml @@ -0,0 +1,5 @@ +--- +title: Fix bug in Container Scanning report remediations +merge_request: 21980 +author: +type: fixed diff --git a/changelogs/unreleased/fix-env-tooltip.yml b/changelogs/unreleased/fix-env-tooltip.yml new file mode 100644 index 0000000000000000000000000000000000000000..15a857924b8343d6d703faf1f3df7ea61abe20c8 --- /dev/null +++ b/changelogs/unreleased/fix-env-tooltip.yml @@ -0,0 +1,5 @@ +--- +title: Update tooltip content for deployment instances +merge_request: 22289 +author: Rajendra Kadam +type: added diff --git a/changelogs/unreleased/fix-group-transfer.yml b/changelogs/unreleased/fix-group-transfer.yml new file mode 100644 index 0000000000000000000000000000000000000000..cf1787aa5af34a5a598558e39d0d68a1005b5de5 --- /dev/null +++ b/changelogs/unreleased/fix-group-transfer.yml @@ -0,0 +1,5 @@ +--- +title: Fix transferring groups to root when EE features are enabled +merge_request: 21915 +author: +type: fixed diff --git a/changelogs/unreleased/fix-linking-merge-requests.yml b/changelogs/unreleased/fix-linking-merge-requests.yml new file mode 100644 index 0000000000000000000000000000000000000000..dbc21802b28f25d2c3dae1d3673374d566325cdb --- /dev/null +++ b/changelogs/unreleased/fix-linking-merge-requests.yml @@ -0,0 +1,5 @@ +--- +title: Enable the linking of merge requests to all non review app deployments +merge_request: +author: +type: added diff --git a/changelogs/unreleased/fix-missing-emoji-import.yml b/changelogs/unreleased/fix-missing-emoji-import.yml new file mode 100644 index 0000000000000000000000000000000000000000..08e2d53013ee856a61134abb1ad779b1ed531530 --- /dev/null +++ b/changelogs/unreleased/fix-missing-emoji-import.yml @@ -0,0 +1,5 @@ +--- +title: Add support to export and import award emojis for issues, issue notes, MR, MR notes and snippet notes +merge_request: 22493 +author: +type: fixed diff --git a/changelogs/unreleased/fix-no-artifacts-when-exposed.yml b/changelogs/unreleased/fix-no-artifacts-when-exposed.yml new file mode 100644 index 0000000000000000000000000000000000000000..7ca77f520a6f10972770a11c1fad487dd604775c --- /dev/null +++ b/changelogs/unreleased/fix-no-artifacts-when-exposed.yml @@ -0,0 +1,5 @@ +--- +title: Fix bug when trying to expose artifacts and no artifacts are produced by the job +merge_request: 22378 +author: +type: fixed diff --git a/changelogs/unreleased/fix-on-train-method-in-mr.yml b/changelogs/unreleased/fix-on-train-method-in-mr.yml new file mode 100644 index 0000000000000000000000000000000000000000..5599964638de62d19d0cbe5e742cb5386c28622c --- /dev/null +++ b/changelogs/unreleased/fix-on-train-method-in-mr.yml @@ -0,0 +1,5 @@ +--- +title: Fix RefreshMergeRequestsService raises an exception and unnecessary sidekiq retry +merge_request: 22262 +author: +type: fixed diff --git a/changelogs/unreleased/fix-prometheus-network-connectivity-error.yml b/changelogs/unreleased/fix-prometheus-network-connectivity-error.yml new file mode 100644 index 0000000000000000000000000000000000000000..c0de02aa9904df9ade52b70736feeacc453686a8 --- /dev/null +++ b/changelogs/unreleased/fix-prometheus-network-connectivity-error.yml @@ -0,0 +1,5 @@ +--- +title: Return 503 error when metrics dashboard has no connectivity +merge_request: 22140 +author: +type: fixed diff --git a/changelogs/unreleased/fix-skip-snippet-raw-buttons-for-external-caching.yml b/changelogs/unreleased/fix-skip-snippet-raw-buttons-for-external-caching.yml new file mode 100644 index 0000000000000000000000000000000000000000..4116d0fb9f98d57dd7615522f7e863e6d93a96bf --- /dev/null +++ b/changelogs/unreleased/fix-skip-snippet-raw-buttons-for-external-caching.yml @@ -0,0 +1,5 @@ +--- +title: Exclude snippets from external caching handling +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/fj-add-migrations-snippet-repository.yml b/changelogs/unreleased/fj-add-migrations-snippet-repository.yml new file mode 100644 index 0000000000000000000000000000000000000000..3a56282a03e9d4dd9ea2dda034035bffb7e3c898 --- /dev/null +++ b/changelogs/unreleased/fj-add-migrations-snippet-repository.yml @@ -0,0 +1,5 @@ +--- +title: Add migrations for version control snippets +merge_request: 22275 +author: +type: added diff --git a/changelogs/unreleased/generate-sample-metrics-intervals.yml b/changelogs/unreleased/generate-sample-metrics-intervals.yml new file mode 100644 index 0000000000000000000000000000000000000000..4434671ec9113134b68610ba16cb5cfae09c8f0b --- /dev/null +++ b/changelogs/unreleased/generate-sample-metrics-intervals.yml @@ -0,0 +1,5 @@ +--- +title: Generate Prometheus sample metrics over pre-set intervals +merge_request: 22066 +author: +type: added diff --git a/changelogs/unreleased/gitaly-cache-invalidator-ff.yml b/changelogs/unreleased/gitaly-cache-invalidator-ff.yml new file mode 100644 index 0000000000000000000000000000000000000000..191dce3f95076a23e2b42de9b62a3c13cdf630bc --- /dev/null +++ b/changelogs/unreleased/gitaly-cache-invalidator-ff.yml @@ -0,0 +1,5 @@ +--- +title: Add back feature flag for cache invalidator +merge_request: 22106 +author: +type: changed diff --git a/changelogs/unreleased/gitaly-version-v1.79.0.yml b/changelogs/unreleased/gitaly-version-v1.79.0.yml new file mode 100644 index 0000000000000000000000000000000000000000..7741f6b74eed40620d3138becb54141880829389 --- /dev/null +++ b/changelogs/unreleased/gitaly-version-v1.79.0.yml @@ -0,0 +1,5 @@ +--- +title: Upgrade to Gitaly v1.79.0 +merge_request: 22515 +author: +type: changed diff --git a/changelogs/unreleased/gitaly-version-v1.81.0.yml b/changelogs/unreleased/gitaly-version-v1.81.0.yml new file mode 100644 index 0000000000000000000000000000000000000000..dd3bf8287615d3d1a050e1e00eca9040e0bec0c0 --- /dev/null +++ b/changelogs/unreleased/gitaly-version-v1.81.0.yml @@ -0,0 +1,5 @@ +--- +title: Upgrade to Gitaly v1.81.0 +merge_request: 23198 +author: +type: changed diff --git a/changelogs/unreleased/himkp-27242.yml b/changelogs/unreleased/himkp-27242.yml new file mode 100644 index 0000000000000000000000000000000000000000..a9c7d69dd26e75688f2a365f70feeed1c5ccd85d --- /dev/null +++ b/changelogs/unreleased/himkp-27242.yml @@ -0,0 +1,5 @@ +--- +title: 'Fix issue: Discard button in Web IDE does nothing' +merge_request: 21902 +author: +type: fixed diff --git a/changelogs/unreleased/himkp-33441.yml b/changelogs/unreleased/himkp-33441.yml new file mode 100644 index 0000000000000000000000000000000000000000..63b7fb43f7a4a30d6be7384e742d90b65ca03090 --- /dev/null +++ b/changelogs/unreleased/himkp-33441.yml @@ -0,0 +1,5 @@ +--- +title: Stage all changes by default in Web IDE +merge_request: 21067 +author: +type: added diff --git a/changelogs/unreleased/icons-to-alerts.yml b/changelogs/unreleased/icons-to-alerts.yml new file mode 100644 index 0000000000000000000000000000000000000000..a7e8b713f6d46c8277ed4814f29bcafb1f589883 --- /dev/null +++ b/changelogs/unreleased/icons-to-alerts.yml @@ -0,0 +1,5 @@ +--- +title: Fix aligment for icons on alerts +merge_request: 22760 +author: Rajendra Kadam +type: added diff --git a/changelogs/unreleased/id-call-lfs-once-when-vue-file-list-enabled.yml b/changelogs/unreleased/id-call-lfs-once-when-vue-file-list-enabled.yml new file mode 100644 index 0000000000000000000000000000000000000000..672ba6717ecab3e868ceca8ad17085912db5bf2a --- /dev/null +++ b/changelogs/unreleased/id-call-lfs-once-when-vue-file-list-enabled.yml @@ -0,0 +1,5 @@ +--- +title: Execute Gitaly LFS call once when Vue file enabled +merge_request: 22168 +author: +type: performance diff --git a/changelogs/unreleased/ide_render_whitespaces.yml b/changelogs/unreleased/ide_render_whitespaces.yml new file mode 100644 index 0000000000000000000000000000000000000000..f554eee42b83ebad1378c3599ff242203632ff47 --- /dev/null +++ b/changelogs/unreleased/ide_render_whitespaces.yml @@ -0,0 +1,5 @@ +--- +title: Render whitespaces in code +merge_request: 17244 +author: Mathieu Parent +type: added diff --git a/changelogs/unreleased/improve-pipeline-processing.yml b/changelogs/unreleased/improve-pipeline-processing.yml new file mode 100644 index 0000000000000000000000000000000000000000..8e93f2d2d4d0e62d5bb45df2ef688d94065f5ad9 --- /dev/null +++ b/changelogs/unreleased/improve-pipeline-processing.yml @@ -0,0 +1,5 @@ +--- +title: Implement Atomic Processing that updates status of builds, stages and pipelines in one go +merge_request: 20229 +author: +type: performance diff --git a/changelogs/unreleased/include-subgroups-in-group-search.yml b/changelogs/unreleased/include-subgroups-in-group-search.yml new file mode 100644 index 0000000000000000000000000000000000000000..9a5fb6804bc8a93b754adde5e1976d16fda54b2d --- /dev/null +++ b/changelogs/unreleased/include-subgroups-in-group-search.yml @@ -0,0 +1,5 @@ +--- +title: Include subgroups when searching inside a group +merge_request: 22991 +author: +type: fixed diff --git a/changelogs/unreleased/issue-28822-add-style-to-no-commit-message.yml b/changelogs/unreleased/issue-28822-add-style-to-no-commit-message.yml new file mode 100644 index 0000000000000000000000000000000000000000..e2cf12299e5ff053a4838e5a63748150588e7eea --- /dev/null +++ b/changelogs/unreleased/issue-28822-add-style-to-no-commit-message.yml @@ -0,0 +1,5 @@ +--- +title: Updated no commit verbiage +merge_request: 22765 +author: +type: other diff --git a/changelogs/unreleased/issue_2030_2.yml b/changelogs/unreleased/issue_2030_2.yml new file mode 100644 index 0000000000000000000000000000000000000000..317a2850fff98cf588f4c3c1db6d15638ddce749 --- /dev/null +++ b/changelogs/unreleased/issue_2030_2.yml @@ -0,0 +1,5 @@ +--- +title: Process quick actions when using Service Desk templates +merge_request: 21948 +author: +type: fixed diff --git a/changelogs/unreleased/jej-prevent-enabling-group-managed-accounts-when-SSO-not-linked.yml b/changelogs/unreleased/jej-prevent-enabling-group-managed-accounts-when-SSO-not-linked.yml new file mode 100644 index 0000000000000000000000000000000000000000..e9ca185f90a576a9555a781d877ebaacb645ede3 --- /dev/null +++ b/changelogs/unreleased/jej-prevent-enabling-group-managed-accounts-when-SSO-not-linked.yml @@ -0,0 +1,5 @@ +--- +title: Require group owner to have linked SAML before enabling Group Managed Accounts +merge_request: 21721 +author: +type: fixed diff --git a/changelogs/unreleased/jl-bump-rack-cors-1-0-6.yml b/changelogs/unreleased/jl-bump-rack-cors-1-0-6.yml new file mode 100644 index 0000000000000000000000000000000000000000..70f0329676848f1a1fc473b04767b11777fc5239 --- /dev/null +++ b/changelogs/unreleased/jl-bump-rack-cors-1-0-6.yml @@ -0,0 +1,5 @@ +--- +title: Update rack-cors to 1.0.6 +merge_request: 22809 +author: +type: security diff --git a/changelogs/unreleased/jl-bump-rdoc-6-2.yml b/changelogs/unreleased/jl-bump-rdoc-6-2.yml new file mode 100644 index 0000000000000000000000000000000000000000..c71df08709d19cba2bddc9b468b730d37d414656 --- /dev/null +++ b/changelogs/unreleased/jl-bump-rdoc-6-2.yml @@ -0,0 +1,5 @@ +--- +title: Update rdoc to 6.1.2 +merge_request: 22434 +author: +type: security diff --git a/changelogs/unreleased/kerrizor-enable-redis-diff-caching-by-default.yml b/changelogs/unreleased/kerrizor-enable-redis-diff-caching-by-default.yml new file mode 100644 index 0000000000000000000000000000000000000000..de22d282635fc536bccff8b89a263b7ec99711d1 --- /dev/null +++ b/changelogs/unreleased/kerrizor-enable-redis-diff-caching-by-default.yml @@ -0,0 +1,5 @@ +--- +title: Enable redis HSET diff caching by default +merge_request: 23105 +author: +type: performance diff --git a/changelogs/unreleased/link-types-api-rest.yml b/changelogs/unreleased/link-types-api-rest.yml new file mode 100644 index 0000000000000000000000000000000000000000..ead1d36a136b20c90b38cfdc5655f042e8a26d4f --- /dev/null +++ b/changelogs/unreleased/link-types-api-rest.yml @@ -0,0 +1,5 @@ +--- +title: Expose issue link type in REST API +merge_request: 21375 +author: +type: added diff --git a/changelogs/unreleased/lm-ignore-sentry-errors-from-list-view.yml b/changelogs/unreleased/lm-ignore-sentry-errors-from-list-view.yml new file mode 100644 index 0000000000000000000000000000000000000000..02da56f02af13abe353322c1e7a6947e962f5900 --- /dev/null +++ b/changelogs/unreleased/lm-ignore-sentry-errors-from-list-view.yml @@ -0,0 +1,5 @@ +--- +title: Implement ability to ignore Sentry errrors from the list view +merge_request: 22819 +author: +type: added diff --git a/changelogs/unreleased/lm-resolve-sentry-errors-from-list-view.yml b/changelogs/unreleased/lm-resolve-sentry-errors-from-list-view.yml new file mode 100644 index 0000000000000000000000000000000000000000..b87f3614ff5b42425edec5f4e70627cc3893002a --- /dev/null +++ b/changelogs/unreleased/lm-resolve-sentry-errors-from-list-view.yml @@ -0,0 +1,5 @@ +--- +title: Resolve Sentry errors from error tracking list +merge_request: 23135 +author: +type: added diff --git a/changelogs/unreleased/lru-object-caching-group-project-object-builder.yml b/changelogs/unreleased/lru-object-caching-group-project-object-builder.yml new file mode 100644 index 0000000000000000000000000000000000000000..bc3f6379de64e2279546184ff42dfff069fc8fdb --- /dev/null +++ b/changelogs/unreleased/lru-object-caching-group-project-object-builder.yml @@ -0,0 +1,5 @@ +--- +title: LRU object caching for GroupProjectObjectBuilder +merge_request: 21823 +author: +type: performance diff --git a/changelogs/unreleased/make-resource-grouped-job-cancellable.yml b/changelogs/unreleased/make-resource-grouped-job-cancellable.yml new file mode 100644 index 0000000000000000000000000000000000000000..8ad2509579c87809a2386effae53b2870fa779bf --- /dev/null +++ b/changelogs/unreleased/make-resource-grouped-job-cancellable.yml @@ -0,0 +1,5 @@ +--- +title: Make jobs with resource group cancellable +merge_request: 22356 +author: +type: fixed diff --git a/changelogs/unreleased/mark-some-as-not-required-during-import.yml b/changelogs/unreleased/mark-some-as-not-required-during-import.yml new file mode 100644 index 0000000000000000000000000000000000000000..1d8449728babce6c6ad69e06555ad3a32bed5ed2 --- /dev/null +++ b/changelogs/unreleased/mark-some-as-not-required-during-import.yml @@ -0,0 +1,5 @@ +--- +title: Add `importing?` to disable some callbacks +merge_request: +author: +type: performance diff --git a/changelogs/unreleased/mk-add-geo-node-name-to-check-task.yml b/changelogs/unreleased/mk-add-geo-node-name-to-check-task.yml new file mode 100644 index 0000000000000000000000000000000000000000..22e304c020627f3ae6cad42109f42acae1a1ed44 --- /dev/null +++ b/changelogs/unreleased/mk-add-geo-node-name-to-check-task.yml @@ -0,0 +1,5 @@ +--- +title: 'Geo: Check current node in gitlab:geo:check Rake task' +merge_request: 22436 +author: +type: added diff --git a/changelogs/unreleased/namespace-labels.yml b/changelogs/unreleased/namespace-labels.yml new file mode 100644 index 0000000000000000000000000000000000000000..231a7d6314039e8245cb63edd56baa0e576300c1 --- /dev/null +++ b/changelogs/unreleased/namespace-labels.yml @@ -0,0 +1,5 @@ +--- +title: Assign labels to the GMA and project k8s namespaces +merge_request: 23027 +author: +type: added diff --git a/changelogs/unreleased/nfriend-add-environments-dashboard-message.yml b/changelogs/unreleased/nfriend-add-environments-dashboard-message.yml new file mode 100644 index 0000000000000000000000000000000000000000..87757bc375802830895679d370c4d3f54d1cb914 --- /dev/null +++ b/changelogs/unreleased/nfriend-add-environments-dashboard-message.yml @@ -0,0 +1,5 @@ +--- +title: Add informational message about page limits to environments dashboard +merge_request: 22489 +author: +type: added diff --git a/changelogs/unreleased/nicolasdular-split-signup-full-name.yml b/changelogs/unreleased/nicolasdular-split-signup-full-name.yml new file mode 100644 index 0000000000000000000000000000000000000000..08c45cb22ba9b39a9c44d89961d0eb15280cc229 --- /dev/null +++ b/changelogs/unreleased/nicolasdular-split-signup-full-name.yml @@ -0,0 +1,5 @@ +--- +title: Update name max length +merge_request: 22840 +author: +type: changed diff --git a/changelogs/unreleased/notes_api_system_filter.yml b/changelogs/unreleased/notes_api_system_filter.yml new file mode 100644 index 0000000000000000000000000000000000000000..f81be1dcb1c001408bc3d4b9282fbc13b2e379a1 --- /dev/null +++ b/changelogs/unreleased/notes_api_system_filter.yml @@ -0,0 +1,5 @@ +--- +title: 25968-activity-filter-to-notes-api +merge_request: 21159 +author: jhenkens +type: added diff --git a/changelogs/unreleased/omniauth-redirect-loop.yml b/changelogs/unreleased/omniauth-redirect-loop.yml new file mode 100644 index 0000000000000000000000000000000000000000..793245f59d97ae93af5c655c8b6a0a51a00dcc9a --- /dev/null +++ b/changelogs/unreleased/omniauth-redirect-loop.yml @@ -0,0 +1,5 @@ +--- +title: "Prevent omniauth signup redirect loop" +merge_request: 22432 +author: Balazs Nagy +type: fixed diff --git a/changelogs/unreleased/parent-child-upstream-downstream-labels.yml b/changelogs/unreleased/parent-child-upstream-downstream-labels.yml new file mode 100644 index 0000000000000000000000000000000000000000..b7fa45f7f61ac1acf6fcd18652b4af488baf7db7 --- /dev/null +++ b/changelogs/unreleased/parent-child-upstream-downstream-labels.yml @@ -0,0 +1,5 @@ +--- +title: Add child and parent labels to pipelines +merge_request: 21332 +author: +type: added diff --git a/changelogs/unreleased/patch-36.yml b/changelogs/unreleased/patch-36.yml new file mode 100644 index 0000000000000000000000000000000000000000..7bf2d2ed1a87791b392cc4171df74e0238db9bc2 --- /dev/null +++ b/changelogs/unreleased/patch-36.yml @@ -0,0 +1,6 @@ +--- +title: refactor javascript to remove Immediately Invoked Function Expression from + project file search +merge_request: 19192 +author: Brian Luckenbill +type: other diff --git a/changelogs/unreleased/ph-195831-hideTabPopoverForAnonUsers.yml b/changelogs/unreleased/ph-195831-hideTabPopoverForAnonUsers.yml new file mode 100644 index 0000000000000000000000000000000000000000..729e7e96477f2ba0fd86ca3638b22b183d45654e --- /dev/null +++ b/changelogs/unreleased/ph-195831-hideTabPopoverForAnonUsers.yml @@ -0,0 +1,5 @@ +--- +title: Hide merge request tab popover for anonymous users +merge_request: 22613 +author: +type: fixed diff --git a/changelogs/unreleased/pl-nil-guard-extract-sentry-external-url.yml b/changelogs/unreleased/pl-nil-guard-extract-sentry-external-url.yml new file mode 100644 index 0000000000000000000000000000000000000000..01a0bbf8326e3505db965528de54dd9a009134bc --- /dev/null +++ b/changelogs/unreleased/pl-nil-guard-extract-sentry-external-url.yml @@ -0,0 +1,5 @@ +--- +title: Fix extracting Sentry external URL when URL is nil +merge_request: 23162 +author: +type: fixed diff --git a/changelogs/unreleased/pokstad1-bump-gitaly-1-80-0.yml b/changelogs/unreleased/pokstad1-bump-gitaly-1-80-0.yml new file mode 100644 index 0000000000000000000000000000000000000000..6623636c4a3a45bf48dcba814d3027149fb73658 --- /dev/null +++ b/changelogs/unreleased/pokstad1-bump-gitaly-1-80-0.yml @@ -0,0 +1,5 @@ +--- +title: Update Gitaly to v1.80.0 +merge_request: 22654 +author: +type: other diff --git a/changelogs/unreleased/polish-user-popover.yml b/changelogs/unreleased/polish-user-popover.yml new file mode 100644 index 0000000000000000000000000000000000000000..0531fc0a5ec5bcf6ba1a42e3ddaf962fc54e2f8c --- /dev/null +++ b/changelogs/unreleased/polish-user-popover.yml @@ -0,0 +1,5 @@ +--- +title: Remove extra whitespace in user popover +merge_request: 19938 +author: +type: fixed diff --git a/changelogs/unreleased/pravi-gitlab-update-d3.yml b/changelogs/unreleased/pravi-gitlab-update-d3.yml new file mode 100644 index 0000000000000000000000000000000000000000..32d492a42faed1ead98980acb7273a8a68e92a46 --- /dev/null +++ b/changelogs/unreleased/pravi-gitlab-update-d3.yml @@ -0,0 +1,5 @@ +--- +title: Update d3 to 5.12 +merge_request: 20627 +author: Praveen Arimbrathodiyil +type: other diff --git a/changelogs/unreleased/prevent-job-logs-line-number-from-being-selected.yml b/changelogs/unreleased/prevent-job-logs-line-number-from-being-selected.yml new file mode 100644 index 0000000000000000000000000000000000000000..292d133780c07454c2d2642ea08d90157859808e --- /dev/null +++ b/changelogs/unreleased/prevent-job-logs-line-number-from-being-selected.yml @@ -0,0 +1,5 @@ +--- +title: Prevent job log line numbers from being selected +merge_request: 22691 +author: +type: fixed diff --git a/changelogs/unreleased/refactor-expose-missing-mention-disabled-group-api.yml b/changelogs/unreleased/refactor-expose-missing-mention-disabled-group-api.yml new file mode 100644 index 0000000000000000000000000000000000000000..f919ee3a00cac76e85564476009a1f6eac46c0de --- /dev/null +++ b/changelogs/unreleased/refactor-expose-missing-mention-disabled-group-api.yml @@ -0,0 +1,5 @@ +--- +title: Expose mentions_disabled value via group API +merge_request: 23070 +author: Fabio Huser +type: added diff --git a/changelogs/unreleased/refactor-session-disable-with-post.yml b/changelogs/unreleased/refactor-session-disable-with-post.yml new file mode 100644 index 0000000000000000000000000000000000000000..63908947095346966e8087e62b04de205c1938ff --- /dev/null +++ b/changelogs/unreleased/refactor-session-disable-with-post.yml @@ -0,0 +1,5 @@ +--- +title: User signout and admin mode disable use now POST instead of GET +merge_request: 22113 +author: Diego Louzán +type: other diff --git a/changelogs/unreleased/remove-ancestor-flag.yml b/changelogs/unreleased/remove-ancestor-flag.yml new file mode 100644 index 0000000000000000000000000000000000000000..2abe15fb72094cbdf42ac5d7dc2e00be29fac388 --- /dev/null +++ b/changelogs/unreleased/remove-ancestor-flag.yml @@ -0,0 +1,5 @@ +--- +title: Remove N+1 query issue when checking group root ancestor. +merge_request: +author: +type: performance diff --git a/changelogs/unreleased/remove-deployment-feature-flags.yml b/changelogs/unreleased/remove-deployment-feature-flags.yml new file mode 100644 index 0000000000000000000000000000000000000000..c93b9d792e575c69d2ae6ebde870c79df661790c --- /dev/null +++ b/changelogs/unreleased/remove-deployment-feature-flags.yml @@ -0,0 +1,5 @@ +--- +title: Track deployed merge requests using GitLab environments and deployments +merge_request: +author: +type: added diff --git a/changelogs/unreleased/remove-feature-flag-import-graceful-failures.yml b/changelogs/unreleased/remove-feature-flag-import-graceful-failures.yml new file mode 100644 index 0000000000000000000000000000000000000000..df6d4429e42c9b6e8ff5be8efa08210d8e93757b --- /dev/null +++ b/changelogs/unreleased/remove-feature-flag-import-graceful-failures.yml @@ -0,0 +1,5 @@ +--- +title: Remove feature flag for import graceful failures +merge_request: +author: +type: other diff --git a/changelogs/unreleased/remove-note-after-initialize.yml b/changelogs/unreleased/remove-note-after-initialize.yml new file mode 100644 index 0000000000000000000000000000000000000000..0fff2669fec69f9087aa20c1069c075d0b6a0287 --- /dev/null +++ b/changelogs/unreleased/remove-note-after-initialize.yml @@ -0,0 +1,5 @@ +--- +title: Remove after_initialize and before_validation for Note +merge_request: 22128 +author: +type: performance diff --git a/changelogs/unreleased/remove-pg-limit-fix.yml b/changelogs/unreleased/remove-pg-limit-fix.yml new file mode 100644 index 0000000000000000000000000000000000000000..dfa29978347559213965b4af6a2405f4fdebe1a0 --- /dev/null +++ b/changelogs/unreleased/remove-pg-limit-fix.yml @@ -0,0 +1,5 @@ +--- +title: Remove ActiveRecord patch to ignore limit on text columns +merge_request: 22406 +author: +type: other diff --git a/changelogs/unreleased/remove-unused-project-import-state-index.yml b/changelogs/unreleased/remove-unused-project-import-state-index.yml new file mode 100644 index 0000000000000000000000000000000000000000..2ffa91e3a457c58a00300e2da2e795709a1cb02e --- /dev/null +++ b/changelogs/unreleased/remove-unused-project-import-state-index.yml @@ -0,0 +1,5 @@ +--- +title: Remove unused index on project_mirror_data +merge_request: 22647 +author: +type: performance diff --git a/changelogs/unreleased/remove_enable_cluster_application_crossplane_flag.yml b/changelogs/unreleased/remove_enable_cluster_application_crossplane_flag.yml new file mode 100644 index 0000000000000000000000000000000000000000..659e8e69ecb00f084bc5e7baac1e0f7430db4b4a --- /dev/null +++ b/changelogs/unreleased/remove_enable_cluster_application_crossplane_flag.yml @@ -0,0 +1,5 @@ +--- +title: Enable ability to install Crossplane app by default +merge_request: 22141 +author: +type: changed diff --git a/changelogs/unreleased/remove_milestone_id_from_epics2.yml b/changelogs/unreleased/remove_milestone_id_from_epics2.yml new file mode 100644 index 0000000000000000000000000000000000000000..4ec65150e72c7ffb432efde9ff78b1e5f2e9f63d --- /dev/null +++ b/changelogs/unreleased/remove_milestone_id_from_epics2.yml @@ -0,0 +1,5 @@ +--- +title: Remove milestone_id from epics +merge_request: 20539 +author: Lee Tickett +type: other diff --git a/changelogs/unreleased/return-empty-body-for-204-responses-in-api.yml b/changelogs/unreleased/return-empty-body-for-204-responses-in-api.yml new file mode 100644 index 0000000000000000000000000000000000000000..17344086b1a9e69deabdebea8da9baa62c9e4bc7 --- /dev/null +++ b/changelogs/unreleased/return-empty-body-for-204-responses-in-api.yml @@ -0,0 +1,5 @@ +--- +title: Return empty body for 204 responses in API +merge_request: 22086 +author: +type: fixed diff --git a/changelogs/unreleased/return_slug_in_api_services_index.yml b/changelogs/unreleased/return_slug_in_api_services_index.yml new file mode 100644 index 0000000000000000000000000000000000000000..ea3e04c845d15d5ef7ec24d4059600d1cf58a61f --- /dev/null +++ b/changelogs/unreleased/return_slug_in_api_services_index.yml @@ -0,0 +1,5 @@ +--- +title: Add slug to services API response +merge_request: 22518 +author: +type: added diff --git a/changelogs/unreleased/revert-knative-version-prerequisite.yml b/changelogs/unreleased/revert-knative-version-prerequisite.yml new file mode 100644 index 0000000000000000000000000000000000000000..bc0bb1e25f333b0c11bd4331bd26e3016d0a7c15 --- /dev/null +++ b/changelogs/unreleased/revert-knative-version-prerequisite.yml @@ -0,0 +1,5 @@ +--- +title: Reverts Add RBAC permissions for getting knative version +merge_request: 22560 +author: +type: fixed diff --git a/changelogs/unreleased/rk-118664-upgrade-monaco-editor.yml b/changelogs/unreleased/rk-118664-upgrade-monaco-editor.yml new file mode 100644 index 0000000000000000000000000000000000000000..53165ca34bbb10484ac74f5d50d4ac3b7aebbcf6 --- /dev/null +++ b/changelogs/unreleased/rk-118664-upgrade-monaco-editor.yml @@ -0,0 +1,5 @@ +--- +title: Updated monaco-editor dependency +merge_request: 21938 +author: +type: other diff --git a/changelogs/unreleased/ruby-2-6-5-source.yml b/changelogs/unreleased/ruby-2-6-5-source.yml new file mode 100644 index 0000000000000000000000000000000000000000..53c27465118be016ec431eb9750af4f5455d4f6e --- /dev/null +++ b/changelogs/unreleased/ruby-2-6-5-source.yml @@ -0,0 +1,5 @@ +--- +title: Update Ruby to 2.6.5 +merge_request: 22417 +author: +type: other diff --git a/changelogs/unreleased/runner-referees.yml b/changelogs/unreleased/runner-referees.yml new file mode 100644 index 0000000000000000000000000000000000000000..c8a1ae81b93c78e560e2a226fbef832593c9b3d6 --- /dev/null +++ b/changelogs/unreleased/runner-referees.yml @@ -0,0 +1,5 @@ +--- +title: Metrics and network referee artifact types added to job artifact types +merge_request: 20181 +author: +type: added diff --git a/changelogs/unreleased/sample-metrics-from-ui.yml b/changelogs/unreleased/sample-metrics-from-ui.yml new file mode 100644 index 0000000000000000000000000000000000000000..e666d4235c0ee0aa450393a33983fdc5cf22b0d7 --- /dev/null +++ b/changelogs/unreleased/sample-metrics-from-ui.yml @@ -0,0 +1,5 @@ +--- +title: Backend for allowing sample metrics to be toggled from ui +merge_request: 22901 +author: +type: added diff --git a/changelogs/unreleased/sample-metrics-without-prometheus.yml b/changelogs/unreleased/sample-metrics-without-prometheus.yml new file mode 100644 index 0000000000000000000000000000000000000000..471af0a909af482dbbf177cd559c780de25c70e2 --- /dev/null +++ b/changelogs/unreleased/sample-metrics-without-prometheus.yml @@ -0,0 +1,5 @@ +--- +title: Show sample metrics for an environment without prometheus configured +merge_request: 22133 +author: +type: added diff --git a/changelogs/unreleased/sh-add-pipeline-index.yml b/changelogs/unreleased/sh-add-pipeline-index.yml new file mode 100644 index 0000000000000000000000000000000000000000..1d7b5edf29a282f0d46f9351b04b84faca63a981 --- /dev/null +++ b/changelogs/unreleased/sh-add-pipeline-index.yml @@ -0,0 +1,5 @@ +--- +title: Add index to optimize loading pipeline charts +merge_request: 22052 +author: +type: performance diff --git a/changelogs/unreleased/sh-bump-json-jwt.yml b/changelogs/unreleased/sh-bump-json-jwt.yml new file mode 100644 index 0000000000000000000000000000000000000000..afa8c8bbf2018da9c3d9b0c49ae712c10c8ef256 --- /dev/null +++ b/changelogs/unreleased/sh-bump-json-jwt.yml @@ -0,0 +1,5 @@ +--- +title: Upgrade json-jwt to v1.11.0 +merge_request: 22440 +author: +type: security diff --git a/changelogs/unreleased/sh-cut-and-paste-spreadsheets-markdown.yml b/changelogs/unreleased/sh-cut-and-paste-spreadsheets-markdown.yml new file mode 100644 index 0000000000000000000000000000000000000000..a388eaf975b47508ccc76ba8c15f4cc350f197ec --- /dev/null +++ b/changelogs/unreleased/sh-cut-and-paste-spreadsheets-markdown.yml @@ -0,0 +1,5 @@ +--- +title: Cut and paste Markdown table from a spreadsheet +merge_request: 22290 +author: +type: added diff --git a/changelogs/unreleased/sh-disable-formats-in-uploads-routes.yml b/changelogs/unreleased/sh-disable-formats-in-uploads-routes.yml new file mode 100644 index 0000000000000000000000000000000000000000..4d0189749ace404b28cc6ac5e7900a61cb0d16e3 --- /dev/null +++ b/changelogs/unreleased/sh-disable-formats-in-uploads-routes.yml @@ -0,0 +1,5 @@ +--- +title: Fix upload redirections when project has moved +merge_request: 22822 +author: +type: fixed diff --git a/changelogs/unreleased/sh-disable-prom-metrics-on-failure.yml b/changelogs/unreleased/sh-disable-prom-metrics-on-failure.yml new file mode 100644 index 0000000000000000000000000000000000000000..d9db2847d2ee212c9271964740f94c3b7aaa31b9 --- /dev/null +++ b/changelogs/unreleased/sh-disable-prom-metrics-on-failure.yml @@ -0,0 +1,5 @@ +--- +title: Disable Prometheus metrics if initialization fails +merge_request: 22355 +author: +type: fixed diff --git a/changelogs/unreleased/sh-drop-ci-pipelines-redundant-index.yml b/changelogs/unreleased/sh-drop-ci-pipelines-redundant-index.yml new file mode 100644 index 0000000000000000000000000000000000000000..c9525f38ffeefbff6cbd799d4debded3c29aaa43 --- /dev/null +++ b/changelogs/unreleased/sh-drop-ci-pipelines-redundant-index.yml @@ -0,0 +1,5 @@ +--- +title: Drop redundant index on ci_pipelines.project_id +merge_request: 22325 +author: +type: other diff --git a/changelogs/unreleased/sh-fix-ci-lint-error.yml b/changelogs/unreleased/sh-fix-ci-lint-error.yml new file mode 100644 index 0000000000000000000000000000000000000000..7ab4b05f00f7ed9c0b4a57c400f05731587e40ed --- /dev/null +++ b/changelogs/unreleased/sh-fix-ci-lint-error.yml @@ -0,0 +1,5 @@ +--- +title: Fix Error 500 in parsing invalid CI needs and dependencies +merge_request: 22567 +author: +type: fixed diff --git a/changelogs/unreleased/sh-fix-ci-lint-errors.yml b/changelogs/unreleased/sh-fix-ci-lint-errors.yml new file mode 100644 index 0000000000000000000000000000000000000000..5f97b98f3d9e1925cc09822a5b06481aef2d0fbf --- /dev/null +++ b/changelogs/unreleased/sh-fix-ci-lint-errors.yml @@ -0,0 +1,5 @@ +--- +title: Gracefully error handle CI lint errors in artifacts section +merge_request: 22388 +author: +type: fixed diff --git a/changelogs/unreleased/sh-fix-issue-197300.yml b/changelogs/unreleased/sh-fix-issue-197300.yml new file mode 100644 index 0000000000000000000000000000000000000000..b54fcd24cc98cd376cc7a1bff1f0ced01e395c2a --- /dev/null +++ b/changelogs/unreleased/sh-fix-issue-197300.yml @@ -0,0 +1,5 @@ +--- +title: Fix issue CSV export failing for some projects +merge_request: 23223 +author: +type: fixed diff --git a/changelogs/unreleased/sh-fix-issue-197464.yml b/changelogs/unreleased/sh-fix-issue-197464.yml new file mode 100644 index 0000000000000000000000000000000000000000..05b68b87d173f75abb2c43113d336eb55df4e927 --- /dev/null +++ b/changelogs/unreleased/sh-fix-issue-197464.yml @@ -0,0 +1,5 @@ +--- +title: Fix analytics tracking for new merge request notes +merge_request: 23273 +author: +type: fixed diff --git a/changelogs/unreleased/sh-fix-mark-for-deletion-service.yml b/changelogs/unreleased/sh-fix-mark-for-deletion-service.yml new file mode 100644 index 0000000000000000000000000000000000000000..2c8a45e8e920b77540cbad0a0b13f66bdd1027d5 --- /dev/null +++ b/changelogs/unreleased/sh-fix-mark-for-deletion-service.yml @@ -0,0 +1,5 @@ +--- +title: Gracefully handle marking a project deletion multiple times +merge_request: 22949 +author: +type: fixed diff --git a/changelogs/unreleased/sh-fix-plugins-not-executing.yml b/changelogs/unreleased/sh-fix-plugins-not-executing.yml new file mode 100644 index 0000000000000000000000000000000000000000..206d8bedc4192057b338f91306034266c9398d63 --- /dev/null +++ b/changelogs/unreleased/sh-fix-plugins-not-executing.yml @@ -0,0 +1,5 @@ +--- +title: Fix GitLab plugins not working without hooks configured +merge_request: 22409 +author: +type: fixed diff --git a/changelogs/unreleased/sh-fix-sidekiq-timestamps.yml b/changelogs/unreleased/sh-fix-sidekiq-timestamps.yml new file mode 100644 index 0000000000000000000000000000000000000000..7da759c4bcac66f64c8d3b724c6fb1a8c99fe10a --- /dev/null +++ b/changelogs/unreleased/sh-fix-sidekiq-timestamps.yml @@ -0,0 +1,5 @@ +--- +title: Make Sidekiq timestamps consistently ISO 8601 +merge_request: 22750 +author: +type: fixed diff --git a/changelogs/unreleased/sh-fix-unique-ips-limiter.yml b/changelogs/unreleased/sh-fix-unique-ips-limiter.yml new file mode 100644 index 0000000000000000000000000000000000000000..d124137e768860fd932260dca46ca7eeb1bc8c03 --- /dev/null +++ b/changelogs/unreleased/sh-fix-unique-ips-limiter.yml @@ -0,0 +1,5 @@ +--- +title: Fix deploy tokens erroneously triggering unique IP limits +merge_request: 22445 +author: +type: fixed diff --git a/changelogs/unreleased/sh-optimize-commit-is-ancestor-env.yml b/changelogs/unreleased/sh-optimize-commit-is-ancestor-env.yml new file mode 100644 index 0000000000000000000000000000000000000000..454303fdaab509d306db0b698b8a63f92c43a2d9 --- /dev/null +++ b/changelogs/unreleased/sh-optimize-commit-is-ancestor-env.yml @@ -0,0 +1,5 @@ +--- +title: Reduce CommitIsAncestor RPCs with environments +merge_request: 21778 +author: +type: performance diff --git a/changelogs/unreleased/sh-skip-findcommit-lookup-rate-limit.yml b/changelogs/unreleased/sh-skip-findcommit-lookup-rate-limit.yml new file mode 100644 index 0000000000000000000000000000000000000000..1e4991d6390c59b5aee392119edcff88ce5cc937 --- /dev/null +++ b/changelogs/unreleased/sh-skip-findcommit-lookup-rate-limit.yml @@ -0,0 +1,5 @@ +--- +title: Avoid Gitaly RPCs in rate-limited raw blob requests +merge_request: 22123 +author: +type: performance diff --git a/changelogs/unreleased/sh-speed-up-build-artifact-entry.yml b/changelogs/unreleased/sh-speed-up-build-artifact-entry.yml new file mode 100644 index 0000000000000000000000000000000000000000..b7c151489f562e0f6746be587e70bd395947ded8 --- /dev/null +++ b/changelogs/unreleased/sh-speed-up-build-artifact-entry.yml @@ -0,0 +1,5 @@ +--- +title: Speed up path generation with build artifacts +merge_request: 22257 +author: +type: performance diff --git a/changelogs/unreleased/sh-update-foreign-key-personal-access-tokens.yml b/changelogs/unreleased/sh-update-foreign-key-personal-access-tokens.yml new file mode 100644 index 0000000000000000000000000000000000000000..d9a339cd8678c7eb48d74b4e651f8e497a25489d --- /dev/null +++ b/changelogs/unreleased/sh-update-foreign-key-personal-access-tokens.yml @@ -0,0 +1,5 @@ +--- +title: Update foreign key constraint for personal access tokens +merge_request: 22305 +author: +type: fixed diff --git a/changelogs/unreleased/sh-update-mermaid-8-4-5.yml b/changelogs/unreleased/sh-update-mermaid-8-4-5.yml new file mode 100644 index 0000000000000000000000000000000000000000..31573f579f63ad7a8b353f4ba7db44f506c750e9 --- /dev/null +++ b/changelogs/unreleased/sh-update-mermaid-8-4-5.yml @@ -0,0 +1,5 @@ +--- +title: Update Mermaid to v8.4.5 +merge_request: 22830 +author: +type: fixed diff --git a/changelogs/unreleased/sh-update-octokit.yml b/changelogs/unreleased/sh-update-octokit.yml new file mode 100644 index 0000000000000000000000000000000000000000..d57a03e03c7af2cb704fc889ff45e38d1de8a2aa --- /dev/null +++ b/changelogs/unreleased/sh-update-octokit.yml @@ -0,0 +1,5 @@ +--- +title: Upgrade octokit and its dependencies +merge_request: 22946 +author: +type: other diff --git a/changelogs/unreleased/shl-disable-animations-config.yml b/changelogs/unreleased/shl-disable-animations-config.yml new file mode 100644 index 0000000000000000000000000000000000000000..97c2db9a1c92b1589d947a095a713cb175650b7d --- /dev/null +++ b/changelogs/unreleased/shl-disable-animations-config.yml @@ -0,0 +1,5 @@ +--- +title: Add a config for disabling CSS and jQuery animations +merge_request: 22217 +author: +type: added diff --git a/changelogs/unreleased/sidekiq-cluster-terminate-hung-workers.yml b/changelogs/unreleased/sidekiq-cluster-terminate-hung-workers.yml new file mode 100644 index 0000000000000000000000000000000000000000..294c739839fbb6f9f8a86838f4a0a7ac28fa28b6 --- /dev/null +++ b/changelogs/unreleased/sidekiq-cluster-terminate-hung-workers.yml @@ -0,0 +1,5 @@ +--- +title: When sidekiq-cluster is asked to shutdown, actively terminate any sidekiq processes that don't finish cleanly in short order +merge_request: 21796 +author: +type: fixed diff --git a/changelogs/unreleased/smaller-prom-redis-keys.yml b/changelogs/unreleased/smaller-prom-redis-keys.yml new file mode 100644 index 0000000000000000000000000000000000000000..a1a9e40d0c3e5cfce12d6c9dc7335e0b5d818360 --- /dev/null +++ b/changelogs/unreleased/smaller-prom-redis-keys.yml @@ -0,0 +1,5 @@ +--- +title: Reduce redis key size for the Prometheus proxy and the amount of queries by half +merge_request: 20006 +author: +type: performance diff --git a/changelogs/unreleased/split-up-relativelinkfilter.yml b/changelogs/unreleased/split-up-relativelinkfilter.yml new file mode 100644 index 0000000000000000000000000000000000000000..feaa9f290abe15d05282cbf0db2824dd23ae4406 --- /dev/null +++ b/changelogs/unreleased/split-up-relativelinkfilter.yml @@ -0,0 +1,5 @@ +--- +title: Avoid making Gitaly calls when some Markdown text links to an uploaded file +merge_request: 22631 +author: +type: performance diff --git a/changelogs/unreleased/standardize-timestamp-format-in-app-logs.yml b/changelogs/unreleased/standardize-timestamp-format-in-app-logs.yml new file mode 100644 index 0000000000000000000000000000000000000000..c4966c2e77ff4707d9e17e9bcc02ad590de11c0f --- /dev/null +++ b/changelogs/unreleased/standardize-timestamp-format-in-app-logs.yml @@ -0,0 +1,5 @@ +--- +title: 'Use IS08601.3 format for app level logging of timestamps' +merge_request: 22793 +author: +type: other diff --git a/changelogs/unreleased/stop-exposing-mr-refs-in-favor-of-persistent-refs.yml b/changelogs/unreleased/stop-exposing-mr-refs-in-favor-of-persistent-refs.yml new file mode 100644 index 0000000000000000000000000000000000000000..c98ca1f1e9526b61b23e0af185171df35e218883 --- /dev/null +++ b/changelogs/unreleased/stop-exposing-mr-refs-in-favor-of-persistent-refs.yml @@ -0,0 +1,5 @@ +--- +title: Stop exposing MR refs in favor of persistent pipeline refs +merge_request: 22198 +author: +type: fixed diff --git a/changelogs/unreleased/sy-bugfix-sentry-id.yml b/changelogs/unreleased/sy-bugfix-sentry-id.yml new file mode 100644 index 0000000000000000000000000000000000000000..d50c03515bb56107da5c82aeae072992242e35c5 --- /dev/null +++ b/changelogs/unreleased/sy-bugfix-sentry-id.yml @@ -0,0 +1,5 @@ +--- +title: Identify correct sentry id in error tracking detail +merge_request: 23280 +author: +type: fixed diff --git a/changelogs/unreleased/tpresa-user-without-project-license-seat.yml b/changelogs/unreleased/tpresa-user-without-project-license-seat.yml new file mode 100644 index 0000000000000000000000000000000000000000..d205b2b878231b32a726422d37d2ab9d2d87201d --- /dev/null +++ b/changelogs/unreleased/tpresa-user-without-project-license-seat.yml @@ -0,0 +1,5 @@ +--- +title: Users without projects use a license seat in a non-premium license +merge_request: 20664 +author: +type: fixed diff --git a/changelogs/unreleased/tr-view-issue-button.yml b/changelogs/unreleased/tr-view-issue-button.yml new file mode 100644 index 0000000000000000000000000000000000000000..2b7ef087d764897368178f69024d7d05a1c4491d --- /dev/null +++ b/changelogs/unreleased/tr-view-issue-button.yml @@ -0,0 +1,5 @@ +--- +title: Add View Issue button to error tracking details page +merge_request: 22862 +author: +type: added diff --git a/changelogs/unreleased/update-codequality-to-0-85-6.yml b/changelogs/unreleased/update-codequality-to-0-85-6.yml new file mode 100644 index 0000000000000000000000000000000000000000..29cb3d34f538ef1d52614727478eeeb6fd3e29eb --- /dev/null +++ b/changelogs/unreleased/update-codequality-to-0-85-6.yml @@ -0,0 +1,5 @@ +--- +title: Update GitLab's codeclimate to 0.85.6 +merge_request: 22659 +author: Takuya Noguchi +type: other diff --git a/changelogs/unreleased/update-gitlab-runner-helm-chart-to-0-12-0.yml b/changelogs/unreleased/update-gitlab-runner-helm-chart-to-0-12-0.yml new file mode 100644 index 0000000000000000000000000000000000000000..3ba0e998e2bb6d9b17a59bee37c51853a66d8e3f --- /dev/null +++ b/changelogs/unreleased/update-gitlab-runner-helm-chart-to-0-12-0.yml @@ -0,0 +1,5 @@ +--- +title: Update GitLab Runner Helm Chart to 0.12.0 +merge_request: 22566 +author: +type: other diff --git a/changelogs/unreleased/update-jupyterhub-chart.yml b/changelogs/unreleased/update-jupyterhub-chart.yml new file mode 100644 index 0000000000000000000000000000000000000000..8e2e1ec090ec147ed823520e78ee68b1b72defb2 --- /dev/null +++ b/changelogs/unreleased/update-jupyterhub-chart.yml @@ -0,0 +1,5 @@ +--- +title: Update jupyterhub chart +merge_request: 22127 +author: +type: changed diff --git a/changelogs/unreleased/update-project-hook-limits-100.yml b/changelogs/unreleased/update-project-hook-limits-100.yml new file mode 100644 index 0000000000000000000000000000000000000000..ae95c78a28a5d6dec4f41f0f05738ff1d18f44df --- /dev/null +++ b/changelogs/unreleased/update-project-hook-limits-100.yml @@ -0,0 +1,5 @@ +--- +title: Update project hooks limits to 100 for all plans +merge_request: 22604 +author: +type: other diff --git a/changelogs/unreleased/update-prometheus-chart.yml b/changelogs/unreleased/update-prometheus-chart.yml new file mode 100644 index 0000000000000000000000000000000000000000..afce595f70e23396497606051544e9a2f38be6eb --- /dev/null +++ b/changelogs/unreleased/update-prometheus-chart.yml @@ -0,0 +1,5 @@ +--- +title: Update prometheus chart version to 9.5.2 +merge_request: 21935 +author: +type: changed diff --git a/changelogs/unreleased/update-set-value-to-2-0-1.yml b/changelogs/unreleased/update-set-value-to-2-0-1.yml new file mode 100644 index 0000000000000000000000000000000000000000..a4d64da276cedaa0a3987309ac50b2b929c54151 --- /dev/null +++ b/changelogs/unreleased/update-set-value-to-2-0-1.yml @@ -0,0 +1,5 @@ +--- +title: Update set-value from 2.0.0 to 2.0.1 +merge_request: 22366 +author: Takuya Noguchi +type: security diff --git a/changelogs/unreleased/update_dast_default_branch.yml b/changelogs/unreleased/update_dast_default_branch.yml new file mode 100644 index 0000000000000000000000000000000000000000..fd241b76a72cc73abb4a3c98be135fc5329db4ad --- /dev/null +++ b/changelogs/unreleased/update_dast_default_branch.yml @@ -0,0 +1,5 @@ +--- +title: Update auto-deploy-image to v0.8.3 for DAST default branch deploy +merge_request: 22227 +author: +type: changed diff --git a/changelogs/unreleased/upgrade-kubeclient-4-4-0-to-4-6-0.yml b/changelogs/unreleased/upgrade-kubeclient-4-4-0-to-4-6-0.yml new file mode 100644 index 0000000000000000000000000000000000000000..772830559b5eb86b0fc0bc1d8bee1a7105f3dad5 --- /dev/null +++ b/changelogs/unreleased/upgrade-kubeclient-4-4-0-to-4-6-0.yml @@ -0,0 +1,5 @@ +--- +title: Bump kubeclient version from 4.4.0 to 4.6.0 +merge_request: 22347 +author: +type: added diff --git a/changelogs/unreleased/wiki-page-message.yml b/changelogs/unreleased/wiki-page-message.yml new file mode 100644 index 0000000000000000000000000000000000000000..028c3cfd1e040373e4218db83a9d3af25220c0a7 --- /dev/null +++ b/changelogs/unreleased/wiki-page-message.yml @@ -0,0 +1,5 @@ +--- +title: Include commit message instead of entire page content in Wiki chat notifications +merge_request: 21722 +author: Ville Skyttä +type: changed diff --git a/config/application.rb b/config/application.rb index 33c1c1b90d2551f0a215cc99e794f2dcd23f7b8c..304cd72e8063db547b58a975aa66f49028fd364b 100644 --- a/config/application.rb +++ b/config/application.rb @@ -18,10 +18,10 @@ module Gitlab require_dependency Rails.root.join('lib/gitlab/redis/cache') require_dependency Rails.root.join('lib/gitlab/redis/queues') require_dependency Rails.root.join('lib/gitlab/redis/shared_state') - require_dependency Rails.root.join('lib/gitlab/request_context') require_dependency Rails.root.join('lib/gitlab/current_settings') require_dependency Rails.root.join('lib/gitlab/middleware/read_only') require_dependency Rails.root.join('lib/gitlab/middleware/basic_health_check') + require_dependency Rails.root.join('lib/gitlab/runtime') # Settings in config/environments/* take precedence over those specified here. # Application configuration should go into files in config/initializers @@ -165,7 +165,7 @@ module Gitlab config.assets.precompile << "page_bundles/xterm.css" config.assets.precompile << "performance_bar.css" config.assets.precompile << "lib/ace.js" - config.assets.precompile << "test.css" + config.assets.precompile << "disable_animations.css" config.assets.precompile << "snippets.css" config.assets.precompile << "locale/**/app.js" config.assets.precompile << "emoji_sprites.css" @@ -228,13 +228,15 @@ module Gitlab # Allow access to GitLab API from other domains config.middleware.insert_before Warden::Manager, Rack::Cors do + headers_to_expose = %w[Link X-Total X-Total-Pages X-Per-Page X-Page X-Next-Page X-Prev-Page X-Gitlab-Blob-Id X-Gitlab-Commit-Id X-Gitlab-Content-Sha256 X-Gitlab-Encoding X-Gitlab-File-Name X-Gitlab-File-Path X-Gitlab-Last-Commit-Id X-Gitlab-Ref X-Gitlab-Size] + allow do origins Gitlab.config.gitlab.url resource '/api/*', credentials: true, headers: :any, methods: :any, - expose: ['Link', 'X-Total', 'X-Total-Pages', 'X-Per-Page', 'X-Page', 'X-Next-Page', 'X-Prev-Page'] + expose: headers_to_expose end # Cross-origin requests must not have the session cookie available @@ -244,7 +246,7 @@ module Gitlab credentials: false, headers: :any, methods: :any, - expose: ['Link', 'X-Total', 'X-Total-Pages', 'X-Per-Page', 'X-Page', 'X-Next-Page', 'X-Prev-Page'] + expose: headers_to_expose end end @@ -255,7 +257,7 @@ module Gitlab caching_config_hash[:compress] = false caching_config_hash[:namespace] = Gitlab::Redis::Cache::CACHE_NAMESPACE caching_config_hash[:expires_in] = 2.weeks # Cache should not grow forever - if Sidekiq.server? || defined?(::Puma) # threaded context + if Gitlab::Runtime.multi_threaded? caching_config_hash[:pool_size] = Gitlab::Redis::Cache.pool_size caching_config_hash[:pool_timeout] = 1 end diff --git a/config/environments/development.rb b/config/environments/development.rb index 2939e13ef943c00148904081ea38ffe1b35969e1..dc804197fefd544d1d27c7261c6502e359342bad 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -46,7 +46,7 @@ Rails.application.configure do # Do not log asset requests config.assets.quiet = true - config.allow_concurrency = defined?(::Puma) + config.allow_concurrency = Gitlab::Runtime.multi_threaded? # BetterErrors live shell (REPL) on every stack frame BetterErrors::Middleware.allow_ip!("127.0.0.1/0") diff --git a/config/environments/production.rb b/config/environments/production.rb index 09bcf49a9a5231db4b4fb5cf4fabb3a318881305..7ec18547b2fb65a70348a43ec3a9dbcfec928fbb 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -75,5 +75,5 @@ Rails.application.configure do config.eager_load = true - config.allow_concurrency = defined?(::Puma) + config.allow_concurrency = Gitlab::Runtime.multi_threaded? end diff --git a/config/feature_categories.yml b/config/feature_categories.yml index 59752a81f60a6e87fe67e642838d0b4d40c8b288..1cae9875eac8ab9131e9a3115f26a78e21a88c7a 100644 --- a/config/feature_categories.yml +++ b/config/feature_categories.yml @@ -8,10 +8,10 @@ # --- - accessibility_testing -- account-management -- agile_portfolio_management - analysis -- audit_management +- attack_emulation +- audit_events +- audit_reports - authentication_and_authorization - auto_devops - backup_restore @@ -21,29 +21,32 @@ - cloud_native_installation - cluster_cost_optimization - cluster_monitoring -- code_analytics - code_quality - code_review - collection +- compliance_controls +- compliance_frameworks - container_network_security - container_registry - container_scanning - continuous_delivery - continuous_integration - data_loss_prevention +- ddos_protection - dependency_proxy - dependency_scanning - design_management - devops_score - disaster_recovery - dynamic_application_security_testing +- epics - error_tracking - feature_flags - fuzzing - geo_replication - gitaly +- gitlab_handbook - gitter -- groups - helm_chart_registry - importers - incident_management @@ -55,12 +58,13 @@ - internationalization - issue_tracking - kanban_boards -- kubernetes_configuration +- kubernetes_management - language_specific - license_compliance - live_coding - load_testing - logging +- malware_scanning - metrics - omnibus_package - package_registry @@ -69,7 +73,9 @@ - release_governance - release_orchestration - requirements_management +- responsible_disclosure - review_apps +- roadmaps - runbooks - runner - runtime_application_self_protection @@ -82,8 +88,9 @@ - snippets - source_code_management - static_application_security_testing +- static_site_editor - status_page -- storage_security +- subgroups - synthetic_monitoring - system_testing - templates @@ -100,4 +107,3 @@ - web_ide - web_performance - wiki -- workflow_policies diff --git a/config/gitlab.yml.example b/config/gitlab.yml.example index 5ac9b7ee6e5143eb28aec3fb485e40b200fd43df..5f078459bc245fdd90ca6278899e7dfcf56888da 100644 --- a/config/gitlab.yml.example +++ b/config/gitlab.yml.example @@ -33,6 +33,9 @@ production: &base host: localhost port: 80 # Set to 443 if using HTTPS, see installation.md#using-https for additional HTTPS configuration details https: false # Set to true if using HTTPS, see installation.md#using-https for additional HTTPS configuration details + # The maximum time unicorn/puma can spend on the request. This needs to be smaller than the worker timeout. + # Default is 95% of the worker timeout + max_request_duration_seconds: 57 # Uncomment this line below if your ssh host is different from HTTP/HTTPS one # (you'd obviously need to replace ssh.host_example.com with your own host). @@ -150,6 +153,9 @@ production: &base ## Impersonation settings impersonation_enabled: true + ## Disable jQuery and CSS animations + # disable_animations: 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 @@ -807,7 +813,7 @@ production: &base # CAUTION! # This allows users to login with the specified providers without two factor. Define the allowed providers # using an array, e.g. ["twitter", 'google_oauth2'], or as true/false to allow all providers or none. - # This option should only be configured for providers which already have two factor. + # This option should only be configured for providers which already have two factor. # This configration dose not apply to SAML. # (default: false) allow_bypass_two_factor: ["twitter", 'google_oauth2'] diff --git a/config/initializers/0_runtime_identify.rb b/config/initializers/0_runtime_identify.rb new file mode 100644 index 0000000000000000000000000000000000000000..e6be19ffb79dbacf161629c5367229d59f687462 --- /dev/null +++ b/config/initializers/0_runtime_identify.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +begin + Gitlab::Runtime.identify +rescue Gitlab::Runtime::IdentificationError => e + message = <<-NOTICE + \n!! RUNTIME IDENTIFICATION FAILED: #{e} + Runtime based configuration settings may not work properly. + If you continue to see this error, please file an issue via + https://gitlab.com/gitlab-org/gitlab/issues/new + NOTICE + Gitlab::AppLogger.error(message) + Gitlab::ErrorTracking.track_exception(e) +end diff --git a/config/initializers/1_settings.rb b/config/initializers/1_settings.rb index 691e4339bf0251da4487d4d8e82eaeebdfa96f29..d7d4bd9d3a1ac99ab0f84714598bfded57f59495 100644 --- a/config/initializers/1_settings.rb +++ b/config/initializers/1_settings.rb @@ -162,8 +162,8 @@ Settings.gitlab['default_projects_limit'] ||= 100000 Settings.gitlab['default_branch_protection'] ||= 2 Settings.gitlab['default_can_create_group'] = true if Settings.gitlab['default_can_create_group'].nil? Settings.gitlab['default_theme'] = Gitlab::Themes::APPLICATION_DEFAULT if Settings.gitlab['default_theme'].nil? -Settings.gitlab['host'] ||= ENV['GITLAB_HOST'] || 'localhost' -Settings.gitlab['ssh_host'] ||= Settings.gitlab.host +Settings.gitlab['host'] ||= ENV['GITLAB_HOST'] || 'localhost' +Settings.gitlab['ssh_host'] ||= Settings.gitlab.host Settings.gitlab['https'] = false if Settings.gitlab['https'].nil? Settings.gitlab['port'] ||= ENV['GITLAB_PORT'] || (Settings.gitlab.https ? 443 : 80) Settings.gitlab['relative_url_root'] ||= ENV['RAILS_RELATIVE_URL_ROOT'] || '' @@ -176,10 +176,10 @@ Settings.gitlab['email_display_name'] ||= ENV['GITLAB_EMAIL_DISPLAY_NAME'] || 'G Settings.gitlab['email_reply_to'] ||= ENV['GITLAB_EMAIL_REPLY_TO'] || "noreply@#{Settings.gitlab.host}" Settings.gitlab['email_subject_suffix'] ||= ENV['GITLAB_EMAIL_SUBJECT_SUFFIX'] || "" Settings.gitlab['email_smime'] = SmimeSignatureSettings.parse(Settings.gitlab['email_smime']) -Settings.gitlab['base_url'] ||= Settings.__send__(:build_base_gitlab_url) -Settings.gitlab['url'] ||= Settings.__send__(:build_gitlab_url) -Settings.gitlab['user'] ||= 'git' -Settings.gitlab['user_home'] ||= begin +Settings.gitlab['base_url'] ||= Settings.__send__(:build_base_gitlab_url) +Settings.gitlab['url'] ||= Settings.__send__(:build_gitlab_url) +Settings.gitlab['user'] ||= 'git' +Settings.gitlab['user_home'] ||= begin Etc.getpwnam(Settings.gitlab['user']).dir rescue ArgumentError # no user configured '/home/' + Settings.gitlab['user'] @@ -209,6 +209,7 @@ Settings.gitlab['content_security_policy'] ||= Gitlab::ContentSecurityPolicy::Co 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? +Settings.gitlab['max_request_duration_seconds'] ||= 57 Gitlab.ee do Settings.gitlab['mirror_max_delay'] ||= 300 @@ -257,13 +258,13 @@ Settings.artifacts['object_store'] = ObjectStoreSettings.parse(Settings.artifact # Registry # Settings['registry'] ||= Settingslogic.new({}) -Settings.registry['enabled'] ||= false -Settings.registry['host'] ||= "example.com" -Settings.registry['port'] ||= nil -Settings.registry['api_url'] ||= "http://localhost:5000/" -Settings.registry['key'] ||= nil -Settings.registry['issuer'] ||= nil -Settings.registry['host_port'] ||= [Settings.registry['host'], Settings.registry['port']].compact.join(':') +Settings.registry['enabled'] ||= false +Settings.registry['host'] ||= "example.com" +Settings.registry['port'] ||= nil +Settings.registry['api_url'] ||= "http://localhost:5000/" +Settings.registry['key'] ||= nil +Settings.registry['issuer'] ||= nil +Settings.registry['host_port'] ||= [Settings.registry['host'], Settings.registry['port']].compact.join(':') Settings.registry['path'] = Settings.absolute(Settings.registry['path'] || File.join(Settings.shared['path'], 'registry')) Settings.registry['notifications'] ||= [] @@ -284,13 +285,13 @@ Settings.pages['enabled'] = false if Settings.pages['enabled'].nil? Settings.pages['access_control'] = false if Settings.pages['access_control'].nil? Settings.pages['path'] = Settings.absolute(Settings.pages['path'] || File.join(Settings.shared['path'], "pages")) Settings.pages['https'] = false if Settings.pages['https'].nil? -Settings.pages['host'] ||= "example.com" -Settings.pages['port'] ||= Settings.pages.https ? 443 : 80 -Settings.pages['protocol'] ||= Settings.pages.https ? "https" : "http" -Settings.pages['url'] ||= Settings.__send__(:build_pages_url) -Settings.pages['external_http'] ||= false unless Settings.pages['external_http'].present? -Settings.pages['external_https'] ||= false unless Settings.pages['external_https'].present? -Settings.pages['artifacts_server'] ||= Settings.pages['enabled'] if Settings.pages['artifacts_server'].nil? +Settings.pages['host'] ||= "example.com" +Settings.pages['port'] ||= Settings.pages.https ? 443 : 80 +Settings.pages['protocol'] ||= Settings.pages.https ? "https" : "http" +Settings.pages['url'] ||= Settings.__send__(:build_pages_url) +Settings.pages['external_http'] ||= false unless Settings.pages['external_http'].present? +Settings.pages['external_https'] ||= false unless Settings.pages['external_https'].present? +Settings.pages['artifacts_server'] ||= Settings.pages['enabled'] if Settings.pages['artifacts_server'].nil? Settings.pages['secret_file'] ||= Rails.root.join('.gitlab_pages_secret') # @@ -364,7 +365,7 @@ Gitlab.ee do # To ensure acceptable performance we only allow feature to be used with # multithreaded web-server Puma. This will be removed once download logic is moved # to GitLab workhorse - Settings.dependency_proxy['enabled'] = false unless defined?(::Puma) + Settings.dependency_proxy['enabled'] = false unless Gitlab::Runtime.puma? end # @@ -467,6 +468,9 @@ Settings.cron_jobs['schedule_migrate_external_diffs_worker']['job_class'] = 'Sch Settings.cron_jobs['namespaces_prune_aggregation_schedules_worker'] ||= Settingslogic.new({}) Settings.cron_jobs['namespaces_prune_aggregation_schedules_worker']['cron'] ||= '5 1 * * *' Settings.cron_jobs['namespaces_prune_aggregation_schedules_worker']['job_class'] = 'Namespaces::PruneAggregationSchedulesWorker' +Settings.cron_jobs['container_expiration_policy_worker'] ||= Settingslogic.new({}) +Settings.cron_jobs['container_expiration_policy_worker']['cron'] ||= '50 * * * *' +Settings.cron_jobs['container_expiration_policy_worker']['job_class'] = 'ContainerExpirationPolicyWorker' Gitlab.ee do Settings.cron_jobs['adjourned_group_deletion_worker'] ||= Settingslogic.new({}) @@ -590,7 +594,7 @@ end # Backup # Settings['backup'] ||= Settingslogic.new({}) -Settings.backup['keep_time'] ||= 0 +Settings.backup['keep_time'] ||= 0 Settings.backup['pg_schema'] = nil Settings.backup['path'] = Settings.absolute(Settings.backup['path'] || "tmp/backups/") Settings.backup['archive_permissions'] ||= 0600 diff --git a/config/initializers/7_prometheus_metrics.rb b/config/initializers/7_prometheus_metrics.rb index 383fe6493ad443a9c6291668570cd9bff8bae48e..aa2601ea65037c137b24da5546f6da7dbe445830 100644 --- a/config/initializers/7_prometheus_metrics.rb +++ b/config/initializers/7_prometheus_metrics.rb @@ -4,11 +4,11 @@ require 'prometheus/client' def prometheus_default_multiproc_dir return unless Rails.env.development? || Rails.env.test? - if Sidekiq.server? + if Gitlab::Runtime.sidekiq? Rails.root.join('tmp/prometheus_multiproc_dir/sidekiq') - elsif defined?(Unicorn::Worker) + elsif Gitlab::Runtime.unicorn? Rails.root.join('tmp/prometheus_multiproc_dir/unicorn') - elsif defined?(::Puma) + elsif Gitlab::Runtime.puma? Rails.root.join('tmp/prometheus_multiproc_dir/puma') else Rails.root.join('tmp/prometheus_multiproc_dir') @@ -51,9 +51,9 @@ if !Rails.env.test? && Gitlab::Metrics.prometheus_metrics_enabled? Gitlab::Cluster::LifecycleEvents.on_master_start do ::Prometheus::Client.reinitialize_on_pid_change(force: true) - if defined?(::Unicorn) + if Gitlab::Runtime.unicorn? Gitlab::Metrics::Samplers::UnicornSampler.instance(Settings.monitoring.unicorn_sampler_interval).start - elsif defined?(::Puma) + elsif Gitlab::Runtime.puma? Gitlab::Metrics::Samplers::PumaSampler.instance(Settings.monitoring.puma_sampler_interval).start end @@ -64,7 +64,7 @@ if !Rails.env.test? && Gitlab::Metrics.prometheus_metrics_enabled? end end -if defined?(::Unicorn) || defined?(::Puma) +if Gitlab::Runtime.web_server? Gitlab::Cluster::LifecycleEvents.on_master_start do Gitlab::Metrics::Exporter::WebExporter.instance.start end diff --git a/config/initializers/8_devise.rb b/config/initializers/8_devise.rb index 8d4c5fa382c5099448dea00b334a0f7e8e0ff24d..e1c37caaafdc8371b38d99607719fc9ebc51c04a 100644 --- a/config/initializers/8_devise.rb +++ b/config/initializers/8_devise.rb @@ -203,7 +203,7 @@ Devise.setup do |config| config.navigational_formats = [:"*/*", "*/*", :html, :zip] # The default HTTP method used to sign out a resource. Default is :delete. - config.sign_out_via = :get + config.sign_out_via = :post # ==> OmniAuth # To configure a new OmniAuth provider copy and edit omniauth.rb.sample diff --git a/config/initializers/action_dispatch_journey_formatter.rb b/config/initializers/action_dispatch_journey_formatter.rb new file mode 100644 index 0000000000000000000000000000000000000000..93cf407c73cb6c3b9c4bc8975bad87ded2ee7a61 --- /dev/null +++ b/config/initializers/action_dispatch_journey_formatter.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +# TODO: Eliminate this file when https://github.com/rails/rails/pull/38184 is released. +# Cleanup issue: https://gitlab.com/gitlab-org/gitlab/issues/195841 +ActionDispatch::Journey::Formatter.prepend(Gitlab::Patch::ActionDispatchJourneyFormatter) + +module ActionDispatch + module Journey + module Path + class Pattern + def requirements_for_missing_keys_check + @requirements_for_missing_keys_check ||= requirements.each_with_object({}) do |(key, regex), hash| + hash[key] = /\A#{regex}\Z/ + end + end + end + end + end +end diff --git a/config/initializers/active_record_lifecycle.rb b/config/initializers/active_record_lifecycle.rb index 61f1d29996087039956450102082534dcb7bce5c..2cf0f0439a9d8e384119abec68d0e9588d48ffb4 100644 --- a/config/initializers/active_record_lifecycle.rb +++ b/config/initializers/active_record_lifecycle.rb @@ -2,7 +2,7 @@ # Don't handle sidekiq configuration as it # has its own special active record configuration here -if defined?(ActiveRecord::Base) && !Sidekiq.server? +if defined?(ActiveRecord::Base) && !Gitlab::Runtime.sidekiq? Gitlab::Cluster::LifecycleEvents.on_worker_start do ActiveSupport.on_load(:active_record) do ActiveRecord::Base.establish_connection diff --git a/config/initializers/cluster_events_before_phased_restart.rb b/config/initializers/cluster_events_before_phased_restart.rb index cbb1dd1a53acdb64ddc6cd02c16e4b8da565517d..aae5470d6aeadae96b74591ad6a56b5674946eb6 100644 --- a/config/initializers/cluster_events_before_phased_restart.rb +++ b/config/initializers/cluster_events_before_phased_restart.rb @@ -5,10 +5,8 @@ # # Follow-up the issue: https://gitlab.com/gitlab-org/gitlab/issues/34107 -if defined?(::Puma) +if Gitlab::Runtime.puma? Puma::Cluster.prepend(::Gitlab::Cluster::Mixins::PumaCluster) -end - -if defined?(::Unicorn::HttpServer) +elsif Gitlab::Runtime.unicorn? Unicorn::HttpServer.prepend(::Gitlab::Cluster::Mixins::UnicornHttpServer) end diff --git a/config/initializers/correlation_id.rb b/config/initializers/correlation_id.rb deleted file mode 100644 index 2a7c138dc40fa34f8acf04380dd71aa420e20581..0000000000000000000000000000000000000000 --- a/config/initializers/correlation_id.rb +++ /dev/null @@ -1,3 +0,0 @@ -# frozen_string_literal: true - -Rails.application.config.middleware.use(Gitlab::Middleware::CorrelationId) diff --git a/config/initializers/database_config.rb b/config/initializers/database_config.rb index d8c2821066bb8edd3f4254e758f24b3899dc3214..b5490fc4719256dc25fe65aa769cddcac1f3707b 100644 --- a/config/initializers/database_config.rb +++ b/config/initializers/database_config.rb @@ -1,18 +1,40 @@ # frozen_string_literal: true -# when running on puma, scale connection pool size with the number -# of threads per worker process -if defined?(::Puma) +def log_pool_size(db, previous_pool_size, current_pool_size) + log_message = ["#{db} connection pool size: #{current_pool_size}"] + + if previous_pool_size && current_pool_size > previous_pool_size + log_message << "(increased from #{previous_pool_size} to match thread count)" + end + + Gitlab::AppLogger.debug(log_message.join(' ')) +end + +# When running on multi-threaded runtimes like Puma or Sidekiq, +# set the number of threads per process as the minimum DB connection pool size. +# This is to avoid connectivity issues as was documented here: +# https://github.com/rails/rails/pull/23057 +if Gitlab::Runtime.multi_threaded? + max_threads = Gitlab::Runtime.max_threads db_config = Gitlab::Database.config || Rails.application.config.database_configuration[Rails.env] - puma_options = Puma.cli_config.options + previous_db_pool_size = db_config['pool'] - # We use either the maximum number of threads per worker process, or - # the user specified value, whichever is larger. - desired_pool_size = [db_config['pool'].to_i, puma_options[:max_threads]].max + db_config['pool'] = [db_config['pool'].to_i, max_threads].max - db_config['pool'] = desired_pool_size - - # recreate the connection pool from the new config ActiveRecord::Base.establish_connection(db_config) + + current_db_pool_size = ActiveRecord::Base.connection.pool.size + + log_pool_size('DB', previous_db_pool_size, current_db_pool_size) + + Gitlab.ee do + if Gitlab::Runtime.sidekiq? && Gitlab::Geo.geo_database_configured? + previous_geo_db_pool_size = Rails.configuration.geo_database['pool'] + Rails.configuration.geo_database['pool'] = max_threads + Geo::TrackingBase.establish_connection(Rails.configuration.geo_database) + current_geo_db_pool_size = Geo::TrackingBase.connection_pool.size + log_pool_size('Geo DB', previous_geo_db_pool_size, current_geo_db_pool_size) + end + end end diff --git a/config/initializers/elastic_client_setup.rb b/config/initializers/elastic_client_setup.rb index f38b606b3a8c5ec1d14e385d5b9b62e7ced7f89f..21745bd81d8563fe5fb37037ade2c4453b789415 100644 --- a/config/initializers/elastic_client_setup.rb +++ b/config/initializers/elastic_client_setup.rb @@ -18,6 +18,32 @@ Gitlab.ee do Elasticsearch::Model::ClassMethods.prepend GemExtensions::Elasticsearch::Model::Client Elasticsearch::Model.singleton_class.prepend GemExtensions::Elasticsearch::Model::Client + # This monkey patch cannot be handled by prepend like the above since this + # module is included into other classes. + module Elasticsearch + module Model + module Response + module Base + if Gem::Version.new(Elasticsearch::Model::VERSION) >= Gem::Version.new('7.0.0') + raise "elasticsearch-model was upgraded, please remove this monkey patch in #{__FILE__}" + end + + # Handle ES7 API where total is returned as an object. This + # change is taken from the V7 gem + # https://github.com/elastic/elasticsearch-rails/commit/9c40f630e1b549f0b7889fe33dcd826b485af6fc + # and can be removed when we upgrade the gem to V7 + def total + if response.response['hits']['total'].respond_to?(:keys) + response.response['hits']['total']['value'] + else + response.response['hits']['total'] + end + end + end + end + end + end + ### Modified from elasticsearch-model/lib/elasticsearch/model.rb [ diff --git a/config/initializers/labkit_middleware.rb b/config/initializers/labkit_middleware.rb new file mode 100644 index 0000000000000000000000000000000000000000..ea4103f052fd966642085d736910f41d60b4870e --- /dev/null +++ b/config/initializers/labkit_middleware.rb @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +Rails.application.config.middleware.use(Labkit::Middleware::Rack) diff --git a/config/initializers/lograge.rb b/config/initializers/lograge.rb index a82078627392537a96cd6706f922cc0a8c6144a2..dc064a760338288790f2e1ff3f2748be2600f6bd 100644 --- a/config/initializers/lograge.rb +++ b/config/initializers/lograge.rb @@ -1,5 +1,5 @@ # Only use Lograge for Rails -unless Sidekiq.server? +unless Gitlab::Runtime.sidekiq? filename = File.join(Rails.root, 'log', "#{Rails.env}_json.log") Rails.application.configure do @@ -37,7 +37,7 @@ unless Sidekiq.server? payload[:response] = event.payload[:response] if event.payload[:response] payload[Labkit::Correlation::CorrelationId::LOG_KEY] = Labkit::Correlation::CorrelationId.current_id - if cpu_s = Gitlab::Metrics::System.thread_cpu_duration(::Gitlab::RequestContext.start_thread_cpu_time) + if cpu_s = Gitlab::Metrics::System.thread_cpu_duration(::Gitlab::RequestContext.instance.start_thread_cpu_time) payload[:cpu_s] = cpu_s end diff --git a/config/initializers/postgresql_limit_fix.rb b/config/initializers/postgresql_limit_fix.rb deleted file mode 100644 index 4224d857e8abeda5dad2e4b88fb78d5336051da7..0000000000000000000000000000000000000000 --- a/config/initializers/postgresql_limit_fix.rb +++ /dev/null @@ -1,27 +0,0 @@ -if defined?(ActiveRecord::ConnectionAdapters::PostgreSQLAdapter) - class ActiveRecord::ConnectionAdapters::PostgreSQLAdapter - module LimitFilter - def add_column(table_name, column_name, type, options = {}) - options.delete(:limit) if type == :text - super(table_name, column_name, type, options) - end - - def change_column(table_name, column_name, type, options = {}) - options.delete(:limit) if type == :text - super(table_name, column_name, type, options) - end - end - - prepend ActiveRecord::ConnectionAdapters::PostgreSQLAdapter::LimitFilter - - class TableDefinition - def text(*args) - options = args.extract_options! - options.delete(:limit) - column_names = args - type = :text - column_names.each { |name| column(name, type, options) } - end - end - end -end diff --git a/config/initializers/rack_timeout.rb b/config/initializers/rack_timeout.rb index 246cf3482a4084a5e890790cfdc46245ec050d3e..1f1264de208871f740dfaf7bb4074c068fd0c638 100644 --- a/config/initializers/rack_timeout.rb +++ b/config/initializers/rack_timeout.rb @@ -9,7 +9,7 @@ # and it's used only as the last resort. In such case this termination is # logged and we should fix the potential timeout issue in the code itself. -if defined?(::Puma) && !Rails.env.test? +if Gitlab::Runtime.puma? && !Rails.env.test? require 'rack/timeout/base' Gitlab::Application.configure do |config| diff --git a/config/initializers/request_context.rb b/config/initializers/request_context.rb index 0b485fc1adc5e411efacb01d830869527cbce7a0..f79f1f32d706b1c419f853865ac7eda087d93570 100644 --- a/config/initializers/request_context.rb +++ b/config/initializers/request_context.rb @@ -1,3 +1,3 @@ Rails.application.configure do |config| - config.middleware.insert_after RequestStore::Middleware, Gitlab::RequestContext + config.middleware.insert_after RequestStore::Middleware, Gitlab::Middleware::RequestContext end diff --git a/config/initializers/retriable.rb b/config/initializers/retriable.rb new file mode 100644 index 0000000000000000000000000000000000000000..3c673cc7513f5186f16f1cba1ec397027c7a289b --- /dev/null +++ b/config/initializers/retriable.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +Retriable.configure do |config| + config.contexts[:relation_import] = { + tries: ENV.fetch('RELATION_IMPORT_TRIES', 3).to_i, + base_interval: ENV.fetch('RELATION_IMPORT_BASE_INTERVAL', 0.5).to_f, + multiplier: ENV.fetch('RELATION_IMPORT_MULTIPLIER', 1.5).to_f, + rand_factor: ENV.fetch('RELATION_IMPORT_RAND_FACTOR', 0.5).to_f, + on: Gitlab::ImportExport::ImportFailureService::RETRIABLE_EXCEPTIONS + } +end diff --git a/config/initializers/rspec_profiling.rb b/config/initializers/rspec_profiling.rb index 715e17057e04c9a20b676f0ef030bda79c0d0d10..9f29e48a63e1eb6375bc53b3ed8fdefcf2c78f2a 100644 --- a/config/initializers/rspec_profiling.rb +++ b/config/initializers/rspec_profiling.rb @@ -60,6 +60,9 @@ RspecProfiling.configure do |config| RspecProfiling::VCS::Git.prepend(RspecProfilingExt::Git) RspecProfiling::Run.prepend(RspecProfilingExt::Run) config.collector = RspecProfilingExt::Collectors::CSVWithTimestamps - config.csv_path = -> { "rspec_profiling/#{Time.now.to_i}-#{SecureRandom.hex(8)}-rspec-data.csv" } + config.csv_path = -> do + prefix = "#{ENV['CI_JOB_NAME']}-".tr(' ', '-') if ENV['CI_JOB_NAME'] + "rspec_profiling/#{prefix}#{Time.now.to_i}-#{SecureRandom.hex(8)}-rspec-data.csv" + end end end diff --git a/config/initializers/sidekiq.rb b/config/initializers/sidekiq.rb index b4a1e0da41af48df04fe6d4e78671f0949e1c79a..b90a04a19e1c36ebd3dc88a952c823ef7445b804 100644 --- a/config/initializers/sidekiq.rb +++ b/config/initializers/sidekiq.rb @@ -88,23 +88,10 @@ Sidekiq.configure_server do |config| Gitlab::SidekiqVersioning.install! - db_config = Gitlab::Database.config || - Rails.application.config.database_configuration[Rails.env] - db_config['pool'] = Sidekiq.options[:concurrency] - ActiveRecord::Base.establish_connection(db_config) - Rails.logger.debug("Connection Pool size for Sidekiq Server is now: #{ActiveRecord::Base.connection.pool.instance_variable_get('@size')}") # rubocop:disable Gitlab/RailsLogger - Gitlab.ee do Gitlab::Mirror.configure_cron_job! Gitlab::Geo.configure_cron_jobs! - - if Gitlab::Geo.geo_database_configured? - Rails.configuration.geo_database['pool'] = Sidekiq.options[:concurrency] - Geo::TrackingBase.establish_connection(Rails.configuration.geo_database) - - Rails.logger.debug("Connection Pool size for Sidekiq Server is now: #{Geo::TrackingBase.connection_pool.size} (Geo tracking database)") # rubocop:disable Gitlab/RailsLogger - end end # Avoid autoload issue such as 'Mail::Parsers::AddressStruct' diff --git a/config/initializers/tracing.rb b/config/initializers/tracing.rb index 5b55a06692e2191c34e0a5e30c05b9dd976ebb93..aaf74eb4cd304097f685cdfb53d4c5984cbd5867 100644 --- a/config/initializers/tracing.rb +++ b/config/initializers/tracing.rb @@ -2,7 +2,7 @@ if Labkit::Tracing.enabled? Rails.application.configure do |config| - config.middleware.insert_after Gitlab::Middleware::CorrelationId, ::Labkit::Tracing::RackMiddleware + config.middleware.insert_after Labkit::Middleware::Rack, ::Labkit::Tracing::RackMiddleware end # Instrument the Sidekiq client @@ -13,7 +13,7 @@ if Labkit::Tracing.enabled? end # Instrument Sidekiq server calls when running Sidekiq server - if Sidekiq.server? + if Gitlab::Runtime.sidekiq? Sidekiq.configure_server do |config| config.server_middleware do |chain| chain.add Labkit::Tracing::Sidekiq::ServerMiddleware diff --git a/config/initializers/validate_puma.rb b/config/initializers/validate_puma.rb index 64bd6e7bbc110f9ed6a41b62979da29463d6ef7c..5abcfbfe6bea47808661989e93c06bc5166724b7 100644 --- a/config/initializers/validate_puma.rb +++ b/config/initializers/validate_puma.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true -if defined?(::Puma) && ::Puma.cli_config.options[:workers].to_i.zero? +if Gitlab::Runtime.puma? && ::Puma.cli_config.options[:workers].to_i.zero? raise 'Puma is only supported in Cluster-mode: workers > 0' end diff --git a/config/locales/en.yml b/config/locales/en.yml index 950529f0355904069d5e51c5ff2e2c1f7b90232f..dabcefba169e6fffe8e1c6cc08303c29b56b3fe4 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -195,6 +195,8 @@ en: wrong_length: one: is the wrong length (should be 1 character) other: is the wrong length (should be %{count} characters) + search_chars_too_long: Search query is too long (maximum is %{count} characters) + search_terms_too_long: Search query is too long (maximum is %{count} terms) other_than: must be other than %{count} template: body: 'There were problems with the following fields:' diff --git a/config/routes/admin.rb b/config/routes/admin.rb index 9238eae3a8ea122e0227600cedaf044ea8559a29..f363823f80cac111d7d91fb9c93fb930a02f15d9 100644 --- a/config/routes/admin.rb +++ b/config/routes/admin.rb @@ -24,7 +24,7 @@ namespace :admin do end resource :session, only: [:new, :create] do - get 'destroy', action: :destroy, as: :destroy + post 'destroy', action: :destroy, as: :destroy end resource :impersonation, only: :destroy @@ -45,7 +45,6 @@ namespace :admin do scope(path: 'groups/*id', controller: :groups, constraints: { id: Gitlab::PathRegex.full_namespace_route_regex, format: /(html|json|atom)/ }) do - scope(as: :group) do put :members_update get :edit, action: :edit @@ -90,7 +89,6 @@ namespace :admin do path: '/', constraints: { id: Gitlab::PathRegex.project_route_regex }, only: [:show, :destroy]) do - member do put :transfer post :repository_check @@ -118,6 +116,11 @@ namespace :admin do put :clear_repository_check_states match :general, :integrations, :repository, :ci_cd, :reporting, :metrics_and_profiling, :network, :preferences, via: [:get, :patch] get :lets_encrypt_terms_of_service + + post :create_self_monitoring_project + get :status_create_self_monitoring_project + delete :delete_self_monitoring_project + get :status_delete_self_monitoring_project end resources :labels diff --git a/config/routes/group.rb b/config/routes/group.rb index 30671d4e0a1affc9ad3eeb60e7f9f22cc090e255..24957d5ecefb474ae06382ff3376fae082100288 100644 --- a/config/routes/group.rb +++ b/config/routes/group.rb @@ -68,7 +68,7 @@ constraints(::Constraints::GroupUrlConstrainer.new) do resources :uploads, only: [:create] do collection do - get ":secret/:filename", action: :show, as: :show, constraints: { filename: %r{[^/]+} } + get ":secret/:filename", action: :show, as: :show, constraints: { filename: %r{[^/]+} }, format: false, defaults: { format: nil } post :authorize end end diff --git a/config/routes/merge_requests.rb b/config/routes/merge_requests.rb new file mode 100644 index 0000000000000000000000000000000000000000..fd80c21deb1a01020bb19c31ced60569f6fcd990 --- /dev/null +++ b/config/routes/merge_requests.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true +resources :merge_requests, concerns: :awardable, except: [:new, :create, :show], constraints: { id: /\d+/ } do + member do + get :show # Insert this first to ensure redirections using merge_requests#show match this route + get :commit_change_content + post :merge + post :cancel_auto_merge + get :pipeline_status + get :ci_environments_status + post :toggle_subscription + post :remove_wip + post :assign_related_issues + get :discussions, format: :json + post :rebase + get :test_reports + get :exposed_artifacts + + scope constraints: ->(req) { req.format == :json }, as: :json do + get :commits + get :pipelines + get :diffs, to: 'merge_requests/diffs#show' + get :diffs_batch, to: 'merge_requests/diffs#diffs_batch' + get :diffs_metadata, to: 'merge_requests/diffs#diffs_metadata' + get :widget, to: 'merge_requests/content#widget' + get :cached_widget, to: 'merge_requests/content#cached_widget' + end + + scope action: :show do + get :commits, defaults: { tab: 'commits' } + get :pipelines, defaults: { tab: 'pipelines' } + get :diffs, defaults: { tab: 'diffs' } + end + + get :diff_for_path, controller: 'merge_requests/diffs' + + scope controller: 'merge_requests/conflicts' do + get :conflicts, action: :show + get :conflict_for_path + post :resolve_conflicts + end + end + + collection do + get :diff_for_path + post :bulk_update + end + + resources :discussions, only: [:show], constraints: { id: /\h{40}/ } do + member do + post :resolve + delete :resolve, action: :unresolve + end + end +end + +scope path: 'merge_requests', controller: 'merge_requests/creations' do + post '', action: :create, as: nil + + scope path: 'new', as: :new_merge_request do + get '', action: :new + + scope constraints: ->(req) { req.format == :json }, as: :json do + get :diffs + get :pipelines + end + + scope action: :new do + get :diffs, defaults: { tab: 'diffs' } + get :pipelines, defaults: { tab: 'pipelines' } + end + + get :diff_for_path + get :branch_from + get :branch_to + end +end diff --git a/config/routes/project.rb b/config/routes/project.rb index 26808de5b41eb902697d48633ba71acd3105dcb5..09e6b733bff91845086051bb212199b6ea65c025 100644 --- a/config/routes/project.rb +++ b/config/routes/project.rb @@ -29,7 +29,6 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do constraints: { project_id: Gitlab::PathRegex.project_route_regex }, module: :projects, as: :project) do - # Begin of the /-/ scope. # Use this scope for all new project routes. scope '-' do @@ -58,6 +57,8 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do get :trace, defaults: { format: 'json' } get :raw get :terminal + + # This route is also defined in gitlab-workhorse. Make sure to update accordingly. get '/terminal.ws/authorize', to: 'jobs#terminal_websocket_authorize', format: false end @@ -235,11 +236,13 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do get :metrics get :additional_metrics get :metrics_dashboard + + # This route is also defined in gitlab-workhorse. Make sure to update accordingly. get '/terminal.ws/authorize', to: 'environments#terminal_websocket_authorize', format: false get '/prometheus/api/v1/*proxy_path', to: 'environments/prometheus_api#proxy', as: :prometheus_api - get '/sample_metrics', to: 'environments/sample_metrics#query' if ENV['USE_SAMPLE_METRICS'] + get '/sample_metrics', to: 'environments/sample_metrics#query' end collection do @@ -256,20 +259,31 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do end end + namespace :performance_monitoring do + resources :dashboards, only: [:create] + end + + namespace :error_tracking do + resources :projects, only: :index + end + resources :error_tracking, only: [:index], controller: :error_tracking do collection do get ':issue_id/details', to: 'error_tracking#details', as: 'details' get ':issue_id/stack_trace', - to: 'error_tracking#stack_trace', + to: 'error_tracking/stack_traces#index', as: 'stack_trace' - post :list_projects + put ':issue_id', + to: 'error_tracking#update', + as: 'update' end end - # The wiki routing contains wildcard characters so + # The wiki and repository routing contains wildcard characters so # its preferable to keep it below all other project routes + draw :repository_scoped draw :wiki end # End of the /-/ scope. @@ -325,83 +339,18 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do end end - resources :merge_requests, concerns: :awardable, except: [:new, :create, :show], constraints: { id: /\d+/ } do - member do - get :show # Insert this first to ensure redirections using merge_requests#show match this route - get :commit_change_content - post :merge - post :cancel_auto_merge - get :pipeline_status - get :ci_environments_status - post :toggle_subscription - post :remove_wip - post :assign_related_issues - get :discussions, format: :json - post :rebase - get :test_reports - get :exposed_artifacts - - scope constraints: ->(req) { req.format == :json }, as: :json do - get :commits - get :pipelines - get :diffs, to: 'merge_requests/diffs#show' - get :diffs_batch, to: 'merge_requests/diffs#diffs_batch' - get :diffs_metadata, to: 'merge_requests/diffs#diffs_metadata' - get :widget, to: 'merge_requests/content#widget' - get :cached_widget, to: 'merge_requests/content#cached_widget' - end - - scope action: :show do - get :commits, defaults: { tab: 'commits' } - get :pipelines, defaults: { tab: 'pipelines' } - get :diffs, defaults: { tab: 'diffs' } - end + # Unscoped route. It will be replaced with redirect to /-/merge_requests/ + # Issue https://gitlab.com/gitlab-org/gitlab/issues/118849 + draw :merge_requests - get :diff_for_path, controller: 'merge_requests/diffs' - - scope controller: 'merge_requests/conflicts' do - get :conflicts, action: :show - get :conflict_for_path - post :resolve_conflicts - end - end - - collection do - get :diff_for_path - post :bulk_update - end - - resources :discussions, only: [:show], constraints: { id: /\h{40}/ } do - member do - post :resolve - delete :resolve, action: :unresolve - end - end - end - - scope path: 'merge_requests', controller: 'merge_requests/creations' do - post '', action: :create, as: nil - - scope path: 'new', as: :new_merge_request do - get '', action: :new - - scope constraints: ->(req) { req.format == :json }, as: :json do - get :diffs - get :pipelines - end - - scope action: :new do - get :diffs, defaults: { tab: 'diffs' } - get :pipelines, defaults: { tab: 'pipelines' } - end - - get :diff_for_path - get :branch_from - get :branch_to - end + # To ensure an old unscoped routing is used for the UI we need to + # add prefix 'as' to the scope routing and place it below original MR routing. + # Issue https://gitlab.com/gitlab-org/gitlab/issues/118849 + scope '-', as: 'scoped' do + draw :merge_requests end - resources :pipelines, only: [:index, :new, :create, :show] do + resources :pipelines, only: [:index, :new, :create, :show, :destroy] do collection do resource :pipelines_settings, path: 'settings', only: [:show, :update] get :charts @@ -503,7 +452,7 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do resources :uploads, only: [:create] do collection do - get ":secret/:filename", action: :show, as: :show, constraints: { filename: %r{[^/]+} } + get ":secret/:filename", action: :show, as: :show, constraints: { filename: %r{[^/]+} }, format: false, defaults: { format: nil } post :authorize end end @@ -540,6 +489,13 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do # its preferable to keep it below all other project routes draw :repository + # To ensure an old unscoped routing is used for the UI we need to + # add prefix 'as' to the scope routing and place it below original routing. + # Issue https://gitlab.com/gitlab-org/gitlab/issues/118849 + scope '-', as: 'scoped' do + draw :repository + end + # All new routes should go under /-/ scope. # Look for scope '-' at the top of the file. # rubocop: enable Cop/PutProjectRoutesUnderScope diff --git a/config/routes/repository.rb b/config/routes/repository.rb index 4815575ba9f8fa1d8be87e4d55a7d71ed74ced3e..d4805b67a5cd6e9b4297f238bf771af2257b3a25 100644 --- a/config/routes/repository.rb +++ b/config/routes/repository.rb @@ -39,32 +39,6 @@ scope format: false do end end - scope path: '-', constraints: { id: Gitlab::PathRegex.git_reference_regex } do - resources :network, only: [:show] - - resources :graphs, only: [:show] do - member do - get :charts - get :commits - get :ci - get :languages - end - end - - get '/branches/:state', to: 'branches#index', as: :branches_filtered, constraints: { state: /active|stale|all/ } - resources :branches, only: [:index, :new, :create, :destroy] do - get :diverging_commit_counts, on: :collection - end - - delete :merged_branches, controller: 'branches', action: :destroy_all_merged - resources :tags, only: [:index, :show, :new, :create, :destroy] do - resource :release, controller: 'tags/releases', only: [:edit, :update] - end - - resources :protected_branches, only: [:index, :show, :create, :update, :destroy, :patch], constraints: { id: Gitlab::PathRegex.git_reference_regex } - resources :protected_tags, only: [:index, :show, :create, :update, :destroy] - end - scope constraints: { id: /[^\0]+/ } do scope controller: :blob do get '/new/*id', action: :new, as: :new_blob diff --git a/config/routes/repository_scoped.rb b/config/routes/repository_scoped.rb new file mode 100644 index 0000000000000000000000000000000000000000..c6343039d629b7b334eab5409fcfd57ef898c631 --- /dev/null +++ b/config/routes/repository_scoped.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +# All routing related to repository browsing +# that is already under /-/ scope only + +# Don't use format parameter as file extension (old 3.0.x behavior) +# See http://guides.rubyonrails.org/routing.html#route-globbing-and-wildcard-segments +scope format: false do + scope constraints: { id: Gitlab::PathRegex.git_reference_regex } do + resources :network, only: [:show] + + resources :graphs, only: [:show] do + member do + get :charts + get :commits + get :ci + get :languages + end + end + + get '/branches/:state', to: 'branches#index', as: :branches_filtered, constraints: { state: /active|stale|all/ } + resources :branches, only: [:index, :new, :create, :destroy] do + get :diverging_commit_counts, on: :collection + end + + delete :merged_branches, controller: 'branches', action: :destroy_all_merged + resources :tags, only: [:index, :show, :new, :create, :destroy] do + resource :release, controller: 'tags/releases', only: [:edit, :update] + end + + resources :protected_branches, only: [:index, :show, :create, :update, :destroy, :patch], constraints: { id: Gitlab::PathRegex.git_reference_regex } + resources :protected_tags, only: [:index, :show, :create, :update, :destroy] + end +end diff --git a/config/routes/uploads.rb b/config/routes/uploads.rb index 096ef146e0780103ac56da6f8c33d257d6840432..fb8af76397c158de233df791d635c6c5590f4d2e 100644 --- a/config/routes/uploads.rb +++ b/config/routes/uploads.rb @@ -21,9 +21,11 @@ scope path: :uploads do as: 'appearance_upload' # Project markdown uploads + # DEPRECATED: Remove this in GitLab 13.0 because this is redundant to show_namespace_project_uploads + # https://gitlab.com/gitlab-org/gitlab/issues/196396 get ":namespace_id/:project_id/:secret/:filename", - to: "projects/uploads#show", - constraints: { namespace_id: /[a-zA-Z.0-9_\-]+/, project_id: /[a-zA-Z.0-9_\-]+/, filename: %r{[^/]+} } + to: redirect("%{namespace_id}/%{project_id}/uploads/%{secret}/%{filename}"), + constraints: { namespace_id: /[a-zA-Z.0-9_\-]+/, project_id: /[a-zA-Z.0-9_\-]+/, filename: %r{[^/]+} }, format: false, defaults: { format: nil } # create uploads for models, snippets (notes) available for now post ':model', diff --git a/config/sidekiq_queues.yml b/config/sidekiq_queues.yml index 68ad819d48bf8f66a2415db6a77ddbb4f4780c47..2986431f21b819e201a8c99e2882a83587894e46 100644 --- a/config/sidekiq_queues.yml +++ b/config/sidekiq_queues.yml @@ -76,7 +76,7 @@ - [pages_domain_ssl_renewal, 1] - [object_storage_upload, 1] - [object_storage, 1] - - [plugin, 1] + - [file_hook, 1] - [pipeline_background, 1] - [repository_update_remote_mirror, 1] - [repository_remove_remote, 1] @@ -99,6 +99,8 @@ - [chaos, 2] - [create_evidence, 2] - [group_export, 1] + - [self_monitoring_project_create, 2] + - [self_monitoring_project_delete, 2] # EE-specific queues - [analytics, 1] @@ -116,6 +118,7 @@ - [elastic_full_index, 1] - [elastic_commit_indexer, 1] - [elastic_namespace_indexer, 1] + - [elastic_namespace_rollout, 1] - [export_csv, 1] - [incident_management, 2] - [jira_connect, 1] diff --git a/config/webpack.config.js b/config/webpack.config.js index d85fa84c32f63f3ebac255494d905d427c93401e..7da7e571d67ae4874840a096b6ceb096e64ce8bc 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -201,7 +201,7 @@ module.exports = { loader: 'raw-loader', }, { - test: /\.(gif|png)$/, + test: /\.(gif|png|mp4)$/, loader: 'url-loader', options: { limit: 2048 }, }, diff --git a/danger/changelog/Dangerfile b/danger/changelog/Dangerfile index 8c010accd56e077b34a1bba58758aa1de41361bd..1c4647121fb8242cf3476391f25b53b0d69a5202 100644 --- a/danger/changelog/Dangerfile +++ b/danger/changelog/Dangerfile @@ -38,9 +38,13 @@ rescue StandardError => e warn "There was a problem trying to check the Changelog. Exception: #{e.name} - #{e.message}" end +def sanitized_mr_title + helper.sanitize_mr_title(gitlab.mr_json["title"]) +end + if git.modified_files.include?("CHANGELOG.md") fail "**CHANGELOG.md was edited.** Please remove the additions and create a CHANGELOG entry.\n\n" + - format(CREATE_CHANGELOG_MESSAGE, mr_iid: gitlab.mr_json["iid"], mr_title: changelog.sanitized_mr_title, labels: changelog.presented_no_changelog_labels) + format(CREATE_CHANGELOG_MESSAGE, mr_iid: gitlab.mr_json["iid"], mr_title: sanitized_mr_title, labels: changelog.presented_no_changelog_labels) end changelog_found = changelog.found @@ -50,6 +54,6 @@ if changelog.needed? check_changelog(changelog_found) else message "**[CHANGELOG missing](https://docs.gitlab.com/ce/development/changelog.html)**: If this merge request [doesn't need a CHANGELOG entry](https://docs.gitlab.com/ee/development/changelog.html#what-warrants-a-changelog-entry), feel free to ignore this message.\n\n" + - format(CREATE_CHANGELOG_MESSAGE, mr_iid: gitlab.mr_json["iid"], mr_title: changelog.sanitized_mr_title, labels: changelog.presented_no_changelog_labels) + format(CREATE_CHANGELOG_MESSAGE, mr_iid: gitlab.mr_json["iid"], mr_title: sanitized_mr_title, labels: changelog.presented_no_changelog_labels) end end diff --git a/danger/commit_messages/Dangerfile b/danger/commit_messages/Dangerfile index 60bc90139abcb0576ff01f0f5d4d98f9fec031af..d6eb050930cf3422ccec125177c85bde69b607e7 100644 --- a/danger/commit_messages/Dangerfile +++ b/danger/commit_messages/Dangerfile @@ -1,301 +1,162 @@ # frozen_string_literal: true -require 'json' +require_relative File.expand_path('../../lib/gitlab/danger/commit_linter', __dir__) -URL_LIMIT_SUBJECT = "https://chris.beams.io/posts/git-commit/#limit-50" URL_GIT_COMMIT = "https://chris.beams.io/posts/git-commit/" - -# rubocop: disable Style/SignalException -# rubocop: disable Metrics/CyclomaticComplexity -# rubocop: disable Metrics/PerceivedComplexity - -# Perform various checks against commits. We're not using -# https://github.com/jonallured/danger-commit_lint because its output is not -# very helpful, and it doesn't offer the means of ignoring merge commits. - -class EmojiChecker - DIGESTS = File.expand_path('../../fixtures/emojis/digests.json', __dir__) - ALIASES = File.expand_path('../../fixtures/emojis/aliases.json', __dir__) - - # A regex that indicates a piece of text _might_ include an Emoji. The regex - # alone is not enough, as we'd match `:foo:bar:baz`. Instead, we use this - # regex to save us from having to check for all possible emoji names when we - # know one definitely is not included. - LIKELY_EMOJI = /:[\+a-z0-9_\-]+:/.freeze - - def initialize - names = JSON.parse(File.read(DIGESTS)).keys + - JSON.parse(File.read(ALIASES)).keys - - @emoji = names.map { |name| ":#{name}:" } - end - - def includes_emoji?(text) - return false unless text.match?(LIKELY_EMOJI) - - @emoji.any? { |emoji| text.include?(emoji) } - end -end +MAX_COMMITS_COUNT = 10 def gitlab_danger @gitlab_danger ||= GitlabDanger.new(helper.gitlab_helper) end def fail_commit(commit, message) - fail("#{commit.sha}: #{message}") + self.fail("#{commit.sha}: #{message}") end def warn_commit(commit, message) - warn("#{commit.sha}: #{message}") + self.warn("#{commit.sha}: #{message}") end -def lines_changed_in_commit(commit) - commit.diff_parent.stats[:total][:lines] +def squash_mr? + gitlab_danger.ci? ? gitlab.mr_json['squash'] : false end -def subject_starts_with_capital?(subject) - first_char = subject.chars.first - - first_char.upcase == first_char +def wip_mr? + gitlab_danger.ci? ? gitlab.mr_json['work_in_progress'] : false end -def ce_upstream? - return unless gitlab_danger.ci? - - gitlab.mr_labels.any? { |label| label == 'CE upstream' } -end - -def too_many_changed_lines?(commit) - commit.diff_parent.stats[:total][:files] > 3 && - lines_changed_in_commit(commit) >= 30 -end - -def emoji_checker - @emoji_checker ||= EmojiChecker.new -end - -def unicode_emoji_regex - @unicode_emoji_regex ||= %r(( - [\u{1F300}-\u{1F5FF}] | - [\u{1F1E6}-\u{1F1FF}] | - [\u{2700}-\u{27BF}] | - [\u{1F900}-\u{1F9FF}] | - [\u{1F600}-\u{1F64F}] | - [\u{1F680}-\u{1F6FF}] | - [\u{2600}-\u{26FF}] - ))x -end - -def count_filtered_commits(commits) - commits.count do |commit| - !commit.message.start_with?('fixup!', 'squash!') - end -end +# Perform various checks against commits. We're not using +# https://github.com/jonallured/danger-commit_lint because its output is not +# very helpful, and it doesn't offer the means of ignoring merge commits. +def lint_commit(commit) + linter = Gitlab::Danger::CommitLinter.new(commit) -def lint_commit(commit) # rubocop:disable Metrics/AbcSize # For now we'll ignore merge commits, as getting rid of those is a problem # separate from enforcing good commit messages. - return false if commit.message.start_with?('Merge branch') + return linter if linter.merge? # We ignore revert commits as they are well structured by Git already - return false if commit.message.start_with?('Revert "') + return linter if linter.revert? - is_squash = gitlab_danger.ci? ? gitlab.mr_json['squash'] : false - is_wip = gitlab_danger.ci? ? gitlab.mr_json['work_in_progress'] : false - is_fixup = commit.message.start_with?('fixup!', 'squash!') + # If MR is set to squash, we ignore fixup commits + return linter if linter.fixup? && squash_mr? - if is_fixup - # The MR is set to squash - Danger adds an informative notice - # The MR is not set to squash - Danger fails. if also WIP warn only, not error - if is_squash - return false - end - - if is_wip - warn_commit( - commit, - 'Squash or Fixup commits must be squashed before merge, or enable squash merge option' - ) + if linter.fixup? + msg = 'Squash or fixup commits must be squashed before merge, or enable squash merge option' + if wip_mr? || squash_mr? + warn_commit(commit, msg) else - fail_commit( - commit, - 'Squash or Fixup commits must be squashed before merge, or enable squash merge option' - ) + fail_commit(commit, msg) end # Makes no sense to process other rules for fixup commits, they trigger just more noise - return false + return linter end # Fail if a suggestion commit is used and squash is not enabled - if commit.message.start_with?('Apply suggestion to') - if is_squash - return false - else - fail_commit( - commit, - 'If you are applying suggestions, enable squash in the merge request and re-run the failed job' - ) - return true + if linter.suggestion? + unless squash_mr? + fail_commit(commit, "If you are applying suggestions, enable squash in the merge request and re-run the `danger-review` job") end - end - failures = false - subject, separator, details = commit.message.split("\n", 3) - - if subject.split.length < 3 - fail_commit( - commit, - 'The commit subject must contain at least three words' - ) - - failures = true - end - - if subject.length > 72 - fail_commit( - commit, - 'The commit subject may not be longer than 72 characters' - ) - - failures = true - elsif subject.length > 50 - warn_commit( - commit, - "This commit's subject line is acceptable, but please try to [reduce it to 50 characters](#{URL_LIMIT_SUBJECT})." - ) - end - - unless subject_starts_with_capital?(subject) - fail_commit(commit, 'The commit subject must start with a capital letter') - failures = true - end - - if subject.end_with?('.') - fail_commit(commit, 'The commit subject must not end with a period') - failures = true + return linter end - if separator && !separator.empty? - fail_commit( - commit, - 'The commit subject and body must be separated by a blank line' - ) - - failures = true - end - - details&.each_line do |line| - line = line.strip - - next if line.length <= 72 - - url_size = line.scan(%r((https?://\S+))).sum { |(url)| url.length } - - # If the line includes a URL, we'll allow it to exceed 72 characters, but - # only if the line _without_ the URL does not exceed this limit. - next if line.length - url_size <= 72 - - fail_commit( - commit, - 'The commit body should not contain more than 72 characters per line' - ) + linter.lint +end - failures = true - end +def lint_mr_title(mr_title) + commit = Struct.new(:message, :sha).new(mr_title) - if !details && too_many_changed_lines?(commit) - fail_commit( - commit, - 'Commits that change 30 or more lines across at least three files ' \ - 'must describe these changes in the commit body' - ) + Gitlab::Danger::CommitLinter.new(commit).lint_subject("merge request title") +end - failures = true - end +def count_non_fixup_commits(commit_linters) + commit_linters.count { |commit_linter| !commit_linter.fixup? } +end - if emoji_checker.includes_emoji?(commit.message) - warn_commit( - commit, - 'Avoid the use of Markdown Emoji such as `:+1:`. ' \ - 'These add limited value to the commit message, ' \ - 'and are displayed as plain text outside of GitLab' - ) +def lint_commits(commits) + commit_linters = commits.map { |commit| lint_commit(commit) } + failed_commit_linters = commit_linters.select { |commit_linter| commit_linter.failed? } + warn_or_fail_commits(failed_commit_linters, default_to_fail: !squash_mr?) - failures = true + if count_non_fixup_commits(commit_linters) > MAX_COMMITS_COUNT + level = squash_mr? ? :warn : :fail + self.__send__(level, # rubocop:disable GitlabSecurity/PublicSend + "This merge request includes more than #{MAX_COMMITS_COUNT} commits. " \ + 'Please rebase these commits into a smaller number of commits or split ' \ + 'this merge request into multiple smaller merge requests.') end - if commit.message.match?(unicode_emoji_regex) - fail_commit( - commit, - 'Avoid the use of Unicode Emoji. ' \ - 'These add no value to the commit message, ' \ - 'and may not be displayed properly everywhere' - ) + if squash_mr? + multi_line_commit_linter = commit_linters.detect { |commit_linter| commit_linter.multi_line? } - failures = true + if multi_line_commit_linter && multi_line_commit_linter.lint.failed? + warn_or_fail_commits(multi_line_commit_linter) + fail_message('The commit message that will be used in the squash commit does not meet our Git commit message standards.') + else + title_linter = lint_mr_title(gitlab.mr_json['title']) + if title_linter.failed? + warn_or_fail_commits(title_linter) + fail_message('The merge request title that will be used in the squash commit does not meet our Git commit message standards.') + end + end + else + if failed_commit_linters.any? + fail_message('One or more commit messages do not meet our Git commit message standards.') + end end +end - if commit.message.match?(%r(([\w\-\/]+)?(#|!|&|%)\d+\b)) - fail_commit( - commit, - 'Use full URLs instead of short references ' \ - '(`gitlab-org/gitlab#123` or `!123`), as short references are ' \ - 'displayed as plain text outside of GitLab' - ) - - failures = true +def warn_or_fail_commits(failed_linters, default_to_fail: true) + level = default_to_fail ? :fail : :warn + + Array(failed_linters).each do |linter| + linter.problems.each do |problem_key, problem_desc| + case problem_key + when :subject_above_warning + warn_commit(linter.commit, problem_desc) + else + self.__send__("#{level}_commit", linter.commit, problem_desc) # rubocop:disable GitlabSecurity/PublicSend + end + end end - - failures end -def lint_commits(commits) - failed = commits.select do |commit| - lint_commit(commit) - end +def fail_message(intro) + markdown(<<~MARKDOWN) + ## Commit message standards - if failed.any? - markdown(<<~MARKDOWN) - ## Commit message standards + #{intro} - One or more commit messages do not meet our Git commit message standards. - For more information on how to write a good commit message, take a look at - [How to Write a Git Commit Message](#{URL_GIT_COMMIT}). + For more information on how to write a good commit message, take a look at + [How to Write a Git Commit Message](#{URL_GIT_COMMIT}). - Here is an example of a good commit message: + Here is an example of a good commit message: - Reject ruby interpolation in externalized strings + Reject ruby interpolation in externalized strings - When using ruby interpolation in externalized strings, they can't be - detected. Which means they will never be presented to be translated. + When using ruby interpolation in externalized strings, they can't be + detected. Which means they will never be presented to be translated. - To mix variables into translations we need to use `sprintf` - instead. + To mix variables into translations we need to use `sprintf` + instead. - Instead of: + Instead of: - _("Hello \#{subject}") + _("Hello \#{subject}") - Use: + Use: - _("Hello %{subject}") % { subject: 'world' } + _("Hello %{subject}") % { subject: 'world' } - This is an example of a bad commit message: + This is an example of a bad commit message: - updated README.md + updated README.md - This commit message is bad because although it tells us that README.md is - updated, it doesn't tell us why or how it was updated. - MARKDOWN - end + This commit message is bad because although it tells us that README.md is + updated, it doesn't tell us why or how it was updated. + MARKDOWN end -if count_filtered_commits(git.commits) > 10 && !ce_upstream? - warn( - 'This merge request includes more than 10 commits. ' \ - 'Please rebase these commits into a smaller number of commits.' - ) -else - lint_commits(git.commits) -end +lint_commits(git.commits) diff --git a/danger/database/Dangerfile b/danger/database/Dangerfile index 56624c0b89718b1770482d8cd4163c030c5fa04d..16740cb867d135db104dbf39bcce518c03c5f2fd 100644 --- a/danger/database/Dangerfile +++ b/danger/database/Dangerfile @@ -20,8 +20,8 @@ changes are reviewed, take the following steps: 1. Ensure the merge request has ~database and ~"database::review pending" labels. If the merge request modifies database files, Danger will do this for you. -1. Use the [Database changes checklist](https://gitlab.com/gitlab-org/gitlab/blob/master/.gitlab/merge_request_templates/Database%20changes.md) - template or add the appropriate items to the MR description. +1. Prepare your MR for database review according to the + [docs](https://docs.gitlab.com/ee/development/database_review.html#how-to-prepare-the-merge-request-for-a-database-review). 1. Assign and mention the database reviewer suggested by Reviewer Roulette. MSG diff --git a/db/fixtures/development/07_milestones.rb b/db/fixtures/development/07_milestones.rb index 8a282562335cf5bb3870cff1258309f259c13df8..880a1211c03894a5c5328b17450b44125c23f269 100644 --- a/db/fixtures/development/07_milestones.rb +++ b/db/fixtures/development/07_milestones.rb @@ -9,8 +9,7 @@ Gitlab::Seeder.quiet do state: [:active, :closed].sample, } - milestone = Milestones::CreateService.new( - project, project.team.users.sample, milestone_params).execute + Milestones::CreateService.new(project, project.team.users.sample, milestone_params).execute print '.' end diff --git a/db/fixtures/development/10_merge_requests.rb b/db/fixtures/development/10_merge_requests.rb index 77650ebb1bcd7d5b3ba8a56f31667cae5c1afea9..9157045a7fdc4e6611afe38b3a8db9d74fb4f516 100644 --- a/db/fixtures/development/10_merge_requests.rb +++ b/db/fixtures/development/10_merge_requests.rb @@ -47,7 +47,7 @@ Gitlab::Seeder.quiet do project = Project.find_by_full_path('gitlab-org/gitlab-test') - next if project.empty_repo? # We don't have repository on CI + next if !project || project.empty_repo? # We don't have repository on CI params = { source_branch: 'feature', diff --git a/db/fixtures/development/14_pipelines.rb b/db/fixtures/development/14_pipelines.rb index 4e9131c1a4620d08d99b8d7be83e6d038fb5794e..417a68d6ad71a51831b3741208ec504a6689b104 100644 --- a/db/fixtures/development/14_pipelines.rb +++ b/db/fixtures/development/14_pipelines.rb @@ -57,7 +57,7 @@ class Gitlab::Seeder::Pipelines BUILDS.each { |opts| build_create!(pipeline, opts) } EXTERNAL_JOBS.each { |opts| commit_status_create!(pipeline, opts) } pipeline.update_duration - pipeline.update_status + pipeline.update_legacy_status end end diff --git a/db/fixtures/development/17_cycle_analytics.rb b/db/fixtures/development/17_cycle_analytics.rb index 2532b71ad2610ba1479612ffd23810d074f6a4ef..b2252d31cacdd7fa9ceca4b04c2db764adc1c25a 100644 --- a/db/fixtures/development/17_cycle_analytics.rb +++ b/db/fixtures/development/17_cycle_analytics.rb @@ -5,7 +5,7 @@ class Gitlab::Seeder::CycleAnalytics def initialize(project, perf: false) @project = project @user = User.admins.first - @issue_count = perf ? 1000 : 5 + @issue_count = perf ? 1000 : ENV.fetch('CYCLE_ANALYTICS_ISSUE_COUNT', 5).to_i end def seed_metrics! @@ -187,7 +187,7 @@ class Gitlab::Seeder::CycleAnalytics pipeline.builds.each(&:enqueue) # make sure all pipelines in pending state pipeline.builds.each(&:run!) - pipeline.update_status + pipeline.update_legacy_status end end @@ -208,7 +208,7 @@ class Gitlab::Seeder::CycleAnalytics job = merge_request.head_pipeline.builds.where.not(environment: nil).last job.success! - pipeline.update_status + job.pipeline.update_legacy_status end end end diff --git a/db/migrate/20180305144721_add_privileged_to_runner.rb b/db/migrate/20180305144721_add_privileged_to_runner.rb index 359498bf9b0ddf085abb390849e0389bbc6c5a8b..1ad3c045d607eb1e47ff5c2799124f1b9f4d9159 100644 --- a/db/migrate/20180305144721_add_privileged_to_runner.rb +++ b/db/migrate/20180305144721_add_privileged_to_runner.rb @@ -9,7 +9,7 @@ class AddPrivilegedToRunner < ActiveRecord::Migration[4.2] disable_ddl_transaction! def up - add_column_with_default :clusters_applications_runners, :privileged, :boolean, default: true, allow_null: false + add_column_with_default :clusters_applications_runners, :privileged, :boolean, default: true, allow_null: false # rubocop:disable Migration/AddColumnWithDefault end def down diff --git a/db/migrate/20180423204600_add_pages_access_level_to_project_feature.rb b/db/migrate/20180423204600_add_pages_access_level_to_project_feature.rb index 0c536f917ce5c72de60ab6eadf54e4d3e1f6bb12..c841c7eb77b6cebd57d992551579d1cbcbdbbb00 100644 --- a/db/migrate/20180423204600_add_pages_access_level_to_project_feature.rb +++ b/db/migrate/20180423204600_add_pages_access_level_to_project_feature.rb @@ -5,7 +5,7 @@ class AddPagesAccessLevelToProjectFeature < ActiveRecord::Migration[4.2] DOWNTIME = false def up - add_column_with_default(:project_features, :pages_access_level, :integer, default: ProjectFeature::PUBLIC, allow_null: false) + add_column_with_default(:project_features, :pages_access_level, :integer, default: ProjectFeature::PUBLIC, allow_null: false) # rubocop:disable Migration/AddColumnWithDefault change_column_default(:project_features, :pages_access_level, ProjectFeature::ENABLED) end diff --git a/db/migrate/20180515005612_add_squash_to_merge_requests.rb b/db/migrate/20180515005612_add_squash_to_merge_requests.rb index 14636d6fd8ef7ac28aefe9ceee0dd7ab85e1c54c..dd301d22614d9d69f5c02207d700814d6e80b3e1 100644 --- a/db/migrate/20180515005612_add_squash_to_merge_requests.rb +++ b/db/migrate/20180515005612_add_squash_to_merge_requests.rb @@ -10,7 +10,7 @@ class AddSquashToMergeRequests < ActiveRecord::Migration[4.2] def up unless column_exists?(:merge_requests, :squash) # rubocop:disable Migration/UpdateLargeTable - add_column_with_default :merge_requests, :squash, :boolean, default: false, allow_null: false + add_column_with_default :merge_requests, :squash, :boolean, default: false, allow_null: false # rubocop:disable Migration/AddColumnWithDefault end end diff --git a/db/migrate/20180529093006_ensure_remote_mirror_columns.rb b/db/migrate/20180529093006_ensure_remote_mirror_columns.rb index a0a1150f022ed1ebc129f60e3c36d8ad43093a78..3c61729dca8da6b69f4bb2dabf2229fe432ff1b2 100644 --- a/db/migrate/20180529093006_ensure_remote_mirror_columns.rb +++ b/db/migrate/20180529093006_ensure_remote_mirror_columns.rb @@ -11,7 +11,7 @@ class EnsureRemoteMirrorColumns < ActiveRecord::Migration[4.2] add_column :remote_mirrors, :remote_name, :string unless column_exists?(:remote_mirrors, :remote_name) # rubocop:disable Migration/AddLimitToStringColumns unless column_exists?(:remote_mirrors, :only_protected_branches) - add_column_with_default(:remote_mirrors, + add_column_with_default(:remote_mirrors, # rubocop:disable Migration/AddColumnWithDefault :only_protected_branches, :boolean, default: false, diff --git a/db/migrate/20180601213245_add_deploy_strategy_to_project_auto_devops.rb b/db/migrate/20180601213245_add_deploy_strategy_to_project_auto_devops.rb index 78a3617ec9336f89345e420227c9081632a00cb4..67d20b949d92ed069143c1d8195dc7e4bf7f9c44 100644 --- a/db/migrate/20180601213245_add_deploy_strategy_to_project_auto_devops.rb +++ b/db/migrate/20180601213245_add_deploy_strategy_to_project_auto_devops.rb @@ -10,7 +10,7 @@ class AddDeployStrategyToProjectAutoDevops < ActiveRecord::Migration[4.2] disable_ddl_transaction! def up - add_column_with_default :project_auto_devops, :deploy_strategy, :integer, default: 0, allow_null: false + add_column_with_default :project_auto_devops, :deploy_strategy, :integer, default: 0, allow_null: false # rubocop:disable Migration/AddColumnWithDefault end def down diff --git a/db/migrate/20180831164905_add_common_to_prometheus_metrics.rb b/db/migrate/20180831164905_add_common_to_prometheus_metrics.rb index 5eb77d0480d487e274af93b00dd2f97630925d49..6654e6d195738fd55683c8445d97fbe9e304f9ac 100644 --- a/db/migrate/20180831164905_add_common_to_prometheus_metrics.rb +++ b/db/migrate/20180831164905_add_common_to_prometheus_metrics.rb @@ -8,7 +8,7 @@ class AddCommonToPrometheusMetrics < ActiveRecord::Migration[4.2] disable_ddl_transaction! def up - add_column_with_default(:prometheus_metrics, :common, :boolean, default: false) + add_column_with_default(:prometheus_metrics, :common, :boolean, default: false) # rubocop:disable Migration/AddColumnWithDefault end def down diff --git a/db/migrate/20180907015926_add_legacy_abac_to_cluster_providers_gcp.rb b/db/migrate/20180907015926_add_legacy_abac_to_cluster_providers_gcp.rb index c57611a0f7db4d26fbe76297685a16516ebd3f59..8bfb0c5612a4b27887bfde310ff8d1e63516d576 100644 --- a/db/migrate/20180907015926_add_legacy_abac_to_cluster_providers_gcp.rb +++ b/db/migrate/20180907015926_add_legacy_abac_to_cluster_providers_gcp.rb @@ -8,7 +8,7 @@ class AddLegacyAbacToClusterProvidersGcp < ActiveRecord::Migration[4.2] disable_ddl_transaction! def up - add_column_with_default(:cluster_providers_gcp, :legacy_abac, :boolean, default: true) + add_column_with_default(:cluster_providers_gcp, :legacy_abac, :boolean, default: true) # rubocop:disable Migration/AddColumnWithDefault end def down diff --git a/db/migrate/20181016141739_add_status_to_deployments.rb b/db/migrate/20181016141739_add_status_to_deployments.rb index 2ff778448b4cd13cfa32fc75cbbf8de6c885d94a..7aaf029b69c21ba41f3cb7174acc9ff1b9ca01fa 100644 --- a/db/migrate/20181016141739_add_status_to_deployments.rb +++ b/db/migrate/20181016141739_add_status_to_deployments.rb @@ -15,7 +15,7 @@ class AddStatusToDeployments < ActiveRecord::Migration[4.2] # However, we have to use the default value for avoiding `NOT NULL` violation during the transition period. # The default value should be removed in the future release. def up - add_column_with_default(:deployments, + add_column_with_default(:deployments, # rubocop:disable Migration/AddColumnWithDefault :status, :integer, limit: 2, diff --git a/db/migrate/20181017001059_add_cluster_type_to_clusters.rb b/db/migrate/20181017001059_add_cluster_type_to_clusters.rb index d032afe1a43a49e17b773364ff2b29557a265592..75abcfedfc926575d4e8007f41abde0560a0fe15 100644 --- a/db/migrate/20181017001059_add_cluster_type_to_clusters.rb +++ b/db/migrate/20181017001059_add_cluster_type_to_clusters.rb @@ -9,7 +9,7 @@ class AddClusterTypeToClusters < ActiveRecord::Migration[4.2] disable_ddl_transaction! def up - add_column_with_default(:clusters, :cluster_type, :smallint, default: PROJECT_CLUSTER_TYPE) + add_column_with_default(:clusters, :cluster_type, :smallint, default: PROJECT_CLUSTER_TYPE) # rubocop:disable Migration/AddColumnWithDefault end def down diff --git a/db/migrate/20190218134158_add_masked_to_ci_variables.rb b/db/migrate/20190218134158_add_masked_to_ci_variables.rb index b4999d5b4a9b872c5f90b5e6d5e7d8ae591cacd0..60dcc0d7af5742be3eb1094b29cd94c31c89209e 100644 --- a/db/migrate/20190218134158_add_masked_to_ci_variables.rb +++ b/db/migrate/20190218134158_add_masked_to_ci_variables.rb @@ -12,7 +12,7 @@ class AddMaskedToCiVariables < ActiveRecord::Migration[5.0] disable_ddl_transaction! def up - add_column_with_default :ci_variables, :masked, :boolean, default: false, allow_null: false + add_column_with_default :ci_variables, :masked, :boolean, default: false, allow_null: false # rubocop:disable Migration/AddColumnWithDefault end def down diff --git a/db/migrate/20190218134209_add_masked_to_ci_group_variables.rb b/db/migrate/20190218134209_add_masked_to_ci_group_variables.rb index 8633875b341076205470dfb39b9f7215303f15bd..c25881410d084dbab4810aae0f62a53dbb3cebf7 100644 --- a/db/migrate/20190218134209_add_masked_to_ci_group_variables.rb +++ b/db/migrate/20190218134209_add_masked_to_ci_group_variables.rb @@ -12,7 +12,7 @@ class AddMaskedToCiGroupVariables < ActiveRecord::Migration[5.0] disable_ddl_transaction! def up - add_column_with_default :ci_group_variables, :masked, :boolean, default: false, allow_null: false + add_column_with_default :ci_group_variables, :masked, :boolean, default: false, allow_null: false # rubocop:disable Migration/AddColumnWithDefault end def down diff --git a/db/migrate/20190220142344_add_email_header_and_footer_enabled_flag_to_appearances_table.rb b/db/migrate/20190220142344_add_email_header_and_footer_enabled_flag_to_appearances_table.rb index 85b9e0580f4bc1b289c3becce42efab2f93e6ea9..33fb6b8ef0d1c2e479ad8ae602a48e6a0cc56fe0 100644 --- a/db/migrate/20190220142344_add_email_header_and_footer_enabled_flag_to_appearances_table.rb +++ b/db/migrate/20190220142344_add_email_header_and_footer_enabled_flag_to_appearances_table.rb @@ -8,7 +8,7 @@ class AddEmailHeaderAndFooterEnabledFlagToAppearancesTable < ActiveRecord::Migra DOWNTIME = false def up - add_column_with_default(:appearances, :email_header_and_footer_enabled, :boolean, default: false) + add_column_with_default(:appearances, :email_header_and_footer_enabled, :boolean, default: false) # rubocop:disable Migration/AddColumnWithDefault end def down diff --git a/db/migrate/20190228192410_add_multi_line_attributes_to_suggestion.rb b/db/migrate/20190228192410_add_multi_line_attributes_to_suggestion.rb index 856dfc89fa394fc3aa786f53b86a24f01b515a5f..766ea50161de08cee2781b67a8673b540c37efe9 100644 --- a/db/migrate/20190228192410_add_multi_line_attributes_to_suggestion.rb +++ b/db/migrate/20190228192410_add_multi_line_attributes_to_suggestion.rb @@ -8,9 +8,11 @@ class AddMultiLineAttributesToSuggestion < ActiveRecord::Migration[5.0] disable_ddl_transaction! def up + # rubocop:disable Migration/AddColumnWithDefault add_column_with_default :suggestions, :lines_above, :integer, default: 0, allow_null: false add_column_with_default :suggestions, :lines_below, :integer, default: 0, allow_null: false add_column_with_default :suggestions, :outdated, :boolean, default: false, allow_null: false + # rubocop:enable Migration/AddColumnWithDefault end def down diff --git a/db/migrate/20190322164830_add_auto_ssl_enabled_to_pages_domain.rb b/db/migrate/20190322164830_add_auto_ssl_enabled_to_pages_domain.rb index e74a9535ddfb22cec496159d70a8324427c94491..41552b0e2e389a4a8d30c4d2fdafa020d87ee54e 100644 --- a/db/migrate/20190322164830_add_auto_ssl_enabled_to_pages_domain.rb +++ b/db/migrate/20190322164830_add_auto_ssl_enabled_to_pages_domain.rb @@ -8,7 +8,7 @@ class AddAutoSslEnabledToPagesDomain < ActiveRecord::Migration[5.0] disable_ddl_transaction! def up - add_column_with_default :pages_domains, :auto_ssl_enabled, :boolean, default: false + add_column_with_default :pages_domains, :auto_ssl_enabled, :boolean, default: false # rubocop:disable Migration/AddColumnWithDefault end def down diff --git a/db/migrate/20190325165127_add_managed_to_cluster.rb b/db/migrate/20190325165127_add_managed_to_cluster.rb index e960df9d5024a7c66f8f2291a1f36bf37a8b8a54..14ed4db143e225ef787319640eeb9e85271837ea 100644 --- a/db/migrate/20190325165127_add_managed_to_cluster.rb +++ b/db/migrate/20190325165127_add_managed_to_cluster.rb @@ -8,7 +8,7 @@ class AddManagedToCluster < ActiveRecord::Migration[5.0] DOWNTIME = false def up - add_column_with_default(:clusters, :managed, :boolean, default: true) + add_column_with_default(:clusters, :managed, :boolean, default: true) # rubocop:disable Migration/AddColumnWithDefault end def down diff --git a/db/migrate/20190415030217_add_variable_type_to_ci_variables.rb b/db/migrate/20190415030217_add_variable_type_to_ci_variables.rb index 433f510299a1b9598dfaf92caee1164bdae7b757..ed7af455e12cc98847773bc7f78dadc3e4f1dd54 100644 --- a/db/migrate/20190415030217_add_variable_type_to_ci_variables.rb +++ b/db/migrate/20190415030217_add_variable_type_to_ci_variables.rb @@ -8,7 +8,7 @@ class AddVariableTypeToCiVariables < ActiveRecord::Migration[5.0] ENV_VAR_VARIABLE_TYPE = 1 def up - add_column_with_default(:ci_variables, :variable_type, :smallint, default: ENV_VAR_VARIABLE_TYPE) + add_column_with_default(:ci_variables, :variable_type, :smallint, default: ENV_VAR_VARIABLE_TYPE) # rubocop:disable Migration/AddColumnWithDefault end def down diff --git a/db/migrate/20190416185130_add_merge_train_enabled_to_ci_cd_settings.rb b/db/migrate/20190416185130_add_merge_train_enabled_to_ci_cd_settings.rb index eb0e7c41f984d331c87d9154179ec0a9cb3a13bc..e6427534310530c7d342afdddd5f94db88f45513 100644 --- a/db/migrate/20190416185130_add_merge_train_enabled_to_ci_cd_settings.rb +++ b/db/migrate/20190416185130_add_merge_train_enabled_to_ci_cd_settings.rb @@ -8,7 +8,7 @@ class AddMergeTrainEnabledToCiCdSettings < ActiveRecord::Migration[5.1] disable_ddl_transaction! def up - add_column_with_default :project_ci_cd_settings, :merge_trains_enabled, :boolean, default: false, allow_null: false + add_column_with_default :project_ci_cd_settings, :merge_trains_enabled, :boolean, default: false, allow_null: false # rubocop:disable Migration/AddColumnWithDefault end def down diff --git a/db/migrate/20190416213556_add_variable_type_to_ci_group_variables.rb b/db/migrate/20190416213556_add_variable_type_to_ci_group_variables.rb index dce73caeb5e418c195c07a57524c68d0e1bcf3b6..4d329cea1b5760b757e5b322b69aa58cedf90636 100644 --- a/db/migrate/20190416213556_add_variable_type_to_ci_group_variables.rb +++ b/db/migrate/20190416213556_add_variable_type_to_ci_group_variables.rb @@ -8,7 +8,7 @@ class AddVariableTypeToCiGroupVariables < ActiveRecord::Migration[5.0] ENV_VAR_VARIABLE_TYPE = 1 def up - add_column_with_default(:ci_group_variables, :variable_type, :smallint, default: ENV_VAR_VARIABLE_TYPE) + add_column_with_default(:ci_group_variables, :variable_type, :smallint, default: ENV_VAR_VARIABLE_TYPE) # rubocop:disable Migration/AddColumnWithDefault end def down diff --git a/db/migrate/20190416213615_add_variable_type_to_ci_pipeline_variables.rb b/db/migrate/20190416213615_add_variable_type_to_ci_pipeline_variables.rb index 1010d9bd29e22650cd416b5fdb6ad9f5adf25482..aa3002a3dcd7494cd79264df582a36c4c01f67a8 100644 --- a/db/migrate/20190416213615_add_variable_type_to_ci_pipeline_variables.rb +++ b/db/migrate/20190416213615_add_variable_type_to_ci_pipeline_variables.rb @@ -8,7 +8,7 @@ class AddVariableTypeToCiPipelineVariables < ActiveRecord::Migration[5.0] ENV_VAR_VARIABLE_TYPE = 1 def up - add_column_with_default(:ci_pipeline_variables, :variable_type, :smallint, default: ENV_VAR_VARIABLE_TYPE) + add_column_with_default(:ci_pipeline_variables, :variable_type, :smallint, default: ENV_VAR_VARIABLE_TYPE) # rubocop:disable Migration/AddColumnWithDefault end def down diff --git a/db/migrate/20190416213631_add_variable_type_to_ci_pipeline_schedule_variables.rb b/db/migrate/20190416213631_add_variable_type_to_ci_pipeline_schedule_variables.rb index 3079b2afd9c070737ac4ff74e9fdf8bf988d6694..b7d80cb2d0d30f3ce8a2bde177daac995a6c0d13 100644 --- a/db/migrate/20190416213631_add_variable_type_to_ci_pipeline_schedule_variables.rb +++ b/db/migrate/20190416213631_add_variable_type_to_ci_pipeline_schedule_variables.rb @@ -8,7 +8,7 @@ class AddVariableTypeToCiPipelineScheduleVariables < ActiveRecord::Migration[5.0 ENV_VAR_VARIABLE_TYPE = 1 def up - add_column_with_default(:ci_pipeline_schedule_variables, :variable_type, :smallint, default: ENV_VAR_VARIABLE_TYPE) + add_column_with_default(:ci_pipeline_schedule_variables, :variable_type, :smallint, default: ENV_VAR_VARIABLE_TYPE) # rubocop:disable Migration/AddColumnWithDefault end def down diff --git a/db/migrate/20190426180107_add_deployment_events_to_services.rb b/db/migrate/20190426180107_add_deployment_events_to_services.rb index 1fb137fb5f9735b9b9ce5283762bc8f766aab1a5..e8e537280106d0c35d9eeba4bcc9b38bde728ce6 100644 --- a/db/migrate/20190426180107_add_deployment_events_to_services.rb +++ b/db/migrate/20190426180107_add_deployment_events_to_services.rb @@ -8,7 +8,7 @@ class AddDeploymentEventsToServices < ActiveRecord::Migration[5.0] disable_ddl_transaction! def up - add_column_with_default(:services, :deployment_events, :boolean, default: false, allow_null: false) + add_column_with_default(:services, :deployment_events, :boolean, default: false, allow_null: false) # rubocop:disable Migration/AddColumnWithDefault end def down diff --git a/db/migrate/20190520200123_add_rule_type_to_approval_merge_request_approval_rules.rb b/db/migrate/20190520200123_add_rule_type_to_approval_merge_request_approval_rules.rb index 7339a4fccbad814a65dc1f11e21e2c76e0051461..7bdb48f3eec4678d66be656d3dd51a92e0c7da37 100644 --- a/db/migrate/20190520200123_add_rule_type_to_approval_merge_request_approval_rules.rb +++ b/db/migrate/20190520200123_add_rule_type_to_approval_merge_request_approval_rules.rb @@ -12,7 +12,7 @@ class AddRuleTypeToApprovalMergeRequestApprovalRules < ActiveRecord::Migration[5 disable_ddl_transaction! def up - add_column_with_default(:approval_merge_request_rules, :rule_type, :integer, limit: 2, default: 1) + add_column_with_default(:approval_merge_request_rules, :rule_type, :integer, limit: 2, default: 1) # rubocop:disable Migration/AddColumnWithDefault end def down diff --git a/db/migrate/20190607085356_add_source_to_pages_domains.rb b/db/migrate/20190607085356_add_source_to_pages_domains.rb index 0a845d7d11f1dd252cf1d774d8bc26e04dad84f4..d681ab674317dfa4de4a06be76b9fd5fa10e0b50 100644 --- a/db/migrate/20190607085356_add_source_to_pages_domains.rb +++ b/db/migrate/20190607085356_add_source_to_pages_domains.rb @@ -12,7 +12,7 @@ class AddSourceToPagesDomains < ActiveRecord::Migration[5.1] disable_ddl_transaction! def up - add_column_with_default(:pages_domains, :certificate_source, :smallint, default: 0) + add_column_with_default(:pages_domains, :certificate_source, :smallint, default: 0) # rubocop:disable Migration/AddColumnWithDefault end def down diff --git a/db/migrate/20190628145246_add_strategies_to_operations_feature_flag_scopes.rb b/db/migrate/20190628145246_add_strategies_to_operations_feature_flag_scopes.rb index ed1f16ee69a4c10dd073e693be31758afd24ae79..030ef9e4bd6caa74ce288f302ac074d4d5bf88a0 100644 --- a/db/migrate/20190628145246_add_strategies_to_operations_feature_flag_scopes.rb +++ b/db/migrate/20190628145246_add_strategies_to_operations_feature_flag_scopes.rb @@ -8,7 +8,7 @@ class AddStrategiesToOperationsFeatureFlagScopes < ActiveRecord::Migration[5.1] disable_ddl_transaction! def up - add_column_with_default :operations_feature_flag_scopes, :strategies, :jsonb, default: [{ name: "default", parameters: {} }] + add_column_with_default :operations_feature_flag_scopes, :strategies, :jsonb, default: [{ name: "default", parameters: {} }] # rubocop:disable Migration/AddColumnWithDefault end def down diff --git a/db/migrate/20190709204413_add_rule_type_to_approval_project_rules.rb b/db/migrate/20190709204413_add_rule_type_to_approval_project_rules.rb index 87a228c9bf9076da6c5510ce202e705834849d9d..b11154d0f26418aebf502ccf57a6242017e19939 100644 --- a/db/migrate/20190709204413_add_rule_type_to_approval_project_rules.rb +++ b/db/migrate/20190709204413_add_rule_type_to_approval_project_rules.rb @@ -8,7 +8,7 @@ class AddRuleTypeToApprovalProjectRules < ActiveRecord::Migration[5.1] disable_ddl_transaction! def up - add_column_with_default :approval_project_rules, :rule_type, :integer, limit: 2, default: 0, allow_null: false + add_column_with_default :approval_project_rules, :rule_type, :integer, limit: 2, default: 0, allow_null: false # rubocop:disable Migration/AddColumnWithDefault end def down diff --git a/db/migrate/20190712064021_add_namespace_per_environment_flag_to_clusters.rb b/db/migrate/20190712064021_add_namespace_per_environment_flag_to_clusters.rb index 4c8a0ab3def993a63afeba934fa8788bee4dfe2d..771eb21c4b667b77834eb2bffc6775bb7d553d7b 100644 --- a/db/migrate/20190712064021_add_namespace_per_environment_flag_to_clusters.rb +++ b/db/migrate/20190712064021_add_namespace_per_environment_flag_to_clusters.rb @@ -11,7 +11,7 @@ class AddNamespacePerEnvironmentFlagToClusters < ActiveRecord::Migration[5.1] disable_ddl_transaction! def up - add_column_with_default :clusters, :namespace_per_environment, :boolean, default: false + add_column_with_default :clusters, :namespace_per_environment, :boolean, default: false # rubocop:disable Migration/AddColumnWithDefault end def down diff --git a/db/migrate/20190715173819_add_object_storage_flag_to_geo_node.rb b/db/migrate/20190715173819_add_object_storage_flag_to_geo_node.rb index 2d3243f3357de6ef45c8d7f8d1cf376ef9345b62..cbc353b6282602d11e5daa6ac59b235ef65fe3fc 100644 --- a/db/migrate/20190715173819_add_object_storage_flag_to_geo_node.rb +++ b/db/migrate/20190715173819_add_object_storage_flag_to_geo_node.rb @@ -12,7 +12,7 @@ class AddObjectStorageFlagToGeoNode < ActiveRecord::Migration[5.2] disable_ddl_transaction! def up - add_column_with_default :geo_nodes, :sync_object_storage, :boolean, default: false + add_column_with_default :geo_nodes, :sync_object_storage, :boolean, default: false # rubocop:disable Migration/AddColumnWithDefault end def down diff --git a/db/migrate/20190729180447_add_merge_requests_require_code_owner_approval_to_protected_branches.rb b/db/migrate/20190729180447_add_merge_requests_require_code_owner_approval_to_protected_branches.rb index 098fcff9acee9cdad19b2103402ef8bc378cec78..bfac67606d6435d213734286b466a78d7c5cd84b 100644 --- a/db/migrate/20190729180447_add_merge_requests_require_code_owner_approval_to_protected_branches.rb +++ b/db/migrate/20190729180447_add_merge_requests_require_code_owner_approval_to_protected_branches.rb @@ -9,7 +9,7 @@ class AddMergeRequestsRequireCodeOwnerApprovalToProtectedBranches < ActiveRecord disable_ddl_transaction! def up - add_column_with_default( + add_column_with_default( # rubocop:disable Migration/AddColumnWithDefault :protected_branches, :code_owner_approval_required, :boolean, diff --git a/db/migrate/20190816151221_add_active_jobs_limit_to_plans.rb b/db/migrate/20190816151221_add_active_jobs_limit_to_plans.rb index 951ff41f1a8cd5865a907d1cbf6ac0b255afff2e..193e6cb188ec8ff2bd072ba4916eb57325098629 100644 --- a/db/migrate/20190816151221_add_active_jobs_limit_to_plans.rb +++ b/db/migrate/20190816151221_add_active_jobs_limit_to_plans.rb @@ -8,7 +8,7 @@ class AddActiveJobsLimitToPlans < ActiveRecord::Migration[5.2] disable_ddl_transaction! def up - add_column_with_default :plans, :active_jobs_limit, :integer, default: 0 + add_column_with_default :plans, :active_jobs_limit, :integer, default: 0 # rubocop:disable Migration/AddColumnWithDefault end def down diff --git a/db/migrate/20190901174200_add_max_issue_count_to_list.rb b/db/migrate/20190901174200_add_max_issue_count_to_list.rb index 59359f28d6a5b7d501bdf520eab3ced82e7664d4..7408d2f1c93eaec26f8cfb12082afde96a5a8ce5 100644 --- a/db/migrate/20190901174200_add_max_issue_count_to_list.rb +++ b/db/migrate/20190901174200_add_max_issue_count_to_list.rb @@ -7,7 +7,7 @@ class AddMaxIssueCountToList < ActiveRecord::Migration[4.2] DOWNTIME = false def up - add_column_with_default :lists, :max_issue_count, :integer, default: 0 + add_column_with_default :lists, :max_issue_count, :integer, default: 0 # rubocop:disable Migration/AddColumnWithDefault end def down diff --git a/db/migrate/20190905140605_add_cloud_run_to_clusters_providers_gcp.rb b/db/migrate/20190905140605_add_cloud_run_to_clusters_providers_gcp.rb index e7ffd7cd4d366a85c6ce206926b8d13210d5fa6c..cd6b2fb7d4faa45cda7e8e6c7f26a46288191ab5 100644 --- a/db/migrate/20190905140605_add_cloud_run_to_clusters_providers_gcp.rb +++ b/db/migrate/20190905140605_add_cloud_run_to_clusters_providers_gcp.rb @@ -8,7 +8,7 @@ class AddCloudRunToClustersProvidersGcp < ActiveRecord::Migration[5.2] disable_ddl_transaction! def up - add_column_with_default(:cluster_providers_gcp, :cloud_run, :boolean, default: false) + add_column_with_default(:cluster_providers_gcp, :cloud_run, :boolean, default: false) # rubocop:disable Migration/AddColumnWithDefault end def down diff --git a/db/migrate/20190907184714_add_show_whitespace_in_diffs_to_user_preferences.rb b/db/migrate/20190907184714_add_show_whitespace_in_diffs_to_user_preferences.rb index 50d5d2b0574af135c6e4215bb87d39b80929d74f..41f9b36278afa78376680efbf06dc05a320421df 100644 --- a/db/migrate/20190907184714_add_show_whitespace_in_diffs_to_user_preferences.rb +++ b/db/migrate/20190907184714_add_show_whitespace_in_diffs_to_user_preferences.rb @@ -11,7 +11,7 @@ class AddShowWhitespaceInDiffsToUserPreferences < ActiveRecord::Migration[5.2] disable_ddl_transaction! def up - add_column_with_default :user_preferences, :show_whitespace_in_diffs, :boolean, default: true, allow_null: false + add_column_with_default :user_preferences, :show_whitespace_in_diffs, :boolean, default: true, allow_null: false # rubocop:disable Migration/AddColumnWithDefault end def down diff --git a/db/migrate/20190918104731_add_cleanup_status_to_cluster.rb b/db/migrate/20190918104731_add_cleanup_status_to_cluster.rb index 0ba9d8e6c8956dacea5ea8d14ec84630bede1ba6..62290fb0fa64f1e2e7ec736cfcaabf23ff4f3c2b 100644 --- a/db/migrate/20190918104731_add_cleanup_status_to_cluster.rb +++ b/db/migrate/20190918104731_add_cleanup_status_to_cluster.rb @@ -9,7 +9,7 @@ class AddCleanupStatusToCluster < ActiveRecord::Migration[5.2] disable_ddl_transaction! def up - add_column_with_default(:clusters, :cleanup_status, + add_column_with_default(:clusters, :cleanup_status, # rubocop:disable Migration/AddColumnWithDefault :smallint, default: 1, allow_null: false) diff --git a/db/migrate/20191014123159_add_expire_notification_delivered_to_personal_access_tokens.rb b/db/migrate/20191014123159_add_expire_notification_delivered_to_personal_access_tokens.rb index f172d3bdcbdab0cb1ed48d66f78f54e44eb43cbe..41a81e3ac875987dd8f419caa648947d1696ec7c 100644 --- a/db/migrate/20191014123159_add_expire_notification_delivered_to_personal_access_tokens.rb +++ b/db/migrate/20191014123159_add_expire_notification_delivered_to_personal_access_tokens.rb @@ -8,7 +8,7 @@ class AddExpireNotificationDeliveredToPersonalAccessTokens < ActiveRecord::Migra disable_ddl_transaction! def up - add_column_with_default :personal_access_tokens, :expire_notification_delivered, :boolean, default: false + add_column_with_default :personal_access_tokens, :expire_notification_delivered, :boolean, default: false # rubocop:disable Migration/AddColumnWithDefault end def down diff --git a/db/migrate/20191023093207_add_comment_actions_to_services.rb b/db/migrate/20191023093207_add_comment_actions_to_services.rb index f3fc12ac7c744a37ccde603340e569309ef91ccc..0bd528cc85d6d6a10c06a530cd8fccc753edf7b3 100644 --- a/db/migrate/20191023093207_add_comment_actions_to_services.rb +++ b/db/migrate/20191023093207_add_comment_actions_to_services.rb @@ -8,7 +8,7 @@ class AddCommentActionsToServices < ActiveRecord::Migration[5.2] disable_ddl_transaction! def up - add_column_with_default(:services, :comment_on_event_enabled, :boolean, default: true) + add_column_with_default(:services, :comment_on_event_enabled, :boolean, default: true) # rubocop:disable Migration/AddColumnWithDefault end def down diff --git a/db/migrate/20191028130054_add_max_issue_weight_to_list.rb b/db/migrate/20191028130054_add_max_issue_weight_to_list.rb index eec7c42c90703d883230bf75d172f18790019d1b..f15b65067f6cc9adac5f06985c2cf54eadc1edef 100644 --- a/db/migrate/20191028130054_add_max_issue_weight_to_list.rb +++ b/db/migrate/20191028130054_add_max_issue_weight_to_list.rb @@ -8,7 +8,7 @@ class AddMaxIssueWeightToList < ActiveRecord::Migration[5.2] DOWNTIME = false def up - add_column_with_default :lists, :max_issue_weight, :integer, default: 0 + add_column_with_default :lists, :max_issue_weight, :integer, default: 0 # rubocop:disable Migration/AddColumnWithDefault end def down diff --git a/db/migrate/20191029191901_add_enabled_to_grafana_integrations.rb b/db/migrate/20191029191901_add_enabled_to_grafana_integrations.rb index 8db11724874fce841e60e4b1a26c84881627a1c8..40e361e2150590ad6721f406b81f2ecf3cdb9a6e 100644 --- a/db/migrate/20191029191901_add_enabled_to_grafana_integrations.rb +++ b/db/migrate/20191029191901_add_enabled_to_grafana_integrations.rb @@ -8,7 +8,7 @@ class AddEnabledToGrafanaIntegrations < ActiveRecord::Migration[5.2] disable_ddl_transaction! def up - add_column_with_default( + add_column_with_default( # rubocop:disable Migration/AddColumnWithDefault :grafana_integrations, :enabled, :boolean, diff --git a/db/migrate/20191105155113_add_secret_to_snippet.rb b/db/migrate/20191105155113_add_secret_to_snippet.rb index ae514d484942f997a0342c96b783e1c2ef253683..8f0a330238b9186929b83e7ad9791221b0399032 100644 --- a/db/migrate/20191105155113_add_secret_to_snippet.rb +++ b/db/migrate/20191105155113_add_secret_to_snippet.rb @@ -9,7 +9,7 @@ class AddSecretToSnippet < ActiveRecord::Migration[5.2] def up unless column_exists?(:snippets, :secret) - add_column_with_default :snippets, :secret, :boolean, default: false + add_column_with_default :snippets, :secret, :boolean, default: false # rubocop:disable Migration/AddColumnWithDefault end add_concurrent_index :snippets, [:visibility_level, :secret] diff --git a/db/migrate/20191106144901_add_state_to_merge_trains.rb b/db/migrate/20191106144901_add_state_to_merge_trains.rb index e2256705f539071d6905e940049d0f3ab86a8ff2..64a70575c911cdbbacf297132217cb832aca3a22 100644 --- a/db/migrate/20191106144901_add_state_to_merge_trains.rb +++ b/db/migrate/20191106144901_add_state_to_merge_trains.rb @@ -9,7 +9,7 @@ class AddStateToMergeTrains < ActiveRecord::Migration[5.2] disable_ddl_transaction! def up - add_column_with_default :merge_trains, :status, :integer, limit: 2, default: MERGE_TRAIN_STATUS_CREATED + add_column_with_default :merge_trains, :status, :integer, limit: 2, default: MERGE_TRAIN_STATUS_CREATED # rubocop:disable Migration/AddColumnWithDefault end def down diff --git a/db/migrate/20191112090226_add_artifacts_to_ci_build_need.rb b/db/migrate/20191112090226_add_artifacts_to_ci_build_need.rb index 2fbd003b2e5495f343a3a5fac70ee8b9f48dcaf2..b868e0b44a83d4350dfba87a0cd74a8e752ed149 100644 --- a/db/migrate/20191112090226_add_artifacts_to_ci_build_need.rb +++ b/db/migrate/20191112090226_add_artifacts_to_ci_build_need.rb @@ -8,7 +8,7 @@ class AddArtifactsToCiBuildNeed < ActiveRecord::Migration[5.2] disable_ddl_transaction! def up - add_column_with_default(:ci_build_needs, :artifacts, + add_column_with_default(:ci_build_needs, :artifacts, # rubocop:disable Migration/AddColumnWithDefault :boolean, default: true, allow_null: false) diff --git a/db/migrate/20191115114032_add_processed_to_ci_builds.rb b/db/migrate/20191115114032_add_processed_to_ci_builds.rb new file mode 100644 index 0000000000000000000000000000000000000000..f6f8f5e85d6b44c7e6b25d3a2da71f7f9fcccb05 --- /dev/null +++ b/db/migrate/20191115114032_add_processed_to_ci_builds.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddProcessedToCiBuilds < ActiveRecord::Migration[5.2] + DOWNTIME = false + + def change + add_column :ci_builds, :processed, :boolean + end +end diff --git a/db/migrate/20191121193110_add_issue_links_type.rb b/db/migrate/20191121193110_add_issue_links_type.rb index 61ef2e7d7e855e54f18f846de6479335e2c5bfeb..86bfd41b916444923be1443e15aae94d3e18e9fe 100644 --- a/db/migrate/20191121193110_add_issue_links_type.rb +++ b/db/migrate/20191121193110_add_issue_links_type.rb @@ -8,7 +8,7 @@ class AddIssueLinksType < ActiveRecord::Migration[5.1] disable_ddl_transaction! def up - add_column_with_default :issue_links, :link_type, :integer, default: 0, limit: 2 + add_column_with_default :issue_links, :link_type, :integer, default: 0, limit: 2 # rubocop:disable Migration/AddColumnWithDefault end def down diff --git a/db/migrate/20191126134210_rename_packages_package_tags.rb b/db/migrate/20191126134210_rename_packages_package_tags.rb new file mode 100644 index 0000000000000000000000000000000000000000..75cb53802abd0c1472591c265a41cfa2a12282cc --- /dev/null +++ b/db/migrate/20191126134210_rename_packages_package_tags.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class RenamePackagesPackageTags < ActiveRecord::Migration[5.2] + DOWNTIME = false + + def change + rename_table(:packages_package_tags, :packages_tags) + end +end diff --git a/db/migrate/20191127221608_add_wildcard_and_domain_type_to_pages_domains.rb b/db/migrate/20191127221608_add_wildcard_and_domain_type_to_pages_domains.rb index 6893a02bcadc5f68601ef57e90bb84ecc173cdf4..4ca7ad296919afaec61cd540d67214955b15ea4e 100644 --- a/db/migrate/20191127221608_add_wildcard_and_domain_type_to_pages_domains.rb +++ b/db/migrate/20191127221608_add_wildcard_and_domain_type_to_pages_domains.rb @@ -9,8 +9,10 @@ class AddWildcardAndDomainTypeToPagesDomains < ActiveRecord::Migration[5.2] disable_ddl_transaction! def up + # rubocop:disable Migration/AddColumnWithDefault add_column_with_default :pages_domains, :wildcard, :boolean, default: false add_column_with_default :pages_domains, :domain_type, :integer, limit: 2, default: PROJECT_TYPE + # rubocop:enable Migration/AddColumnWithDefault end def down diff --git a/db/migrate/20191128145231_add_ci_resource_groups.rb b/db/migrate/20191128145231_add_ci_resource_groups.rb new file mode 100644 index 0000000000000000000000000000000000000000..8bde0254701886413563f720c3fffecd23288568 --- /dev/null +++ b/db/migrate/20191128145231_add_ci_resource_groups.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class AddCiResourceGroups < ActiveRecord::Migration[5.2] + DOWNTIME = false + + def change + create_table :ci_resource_groups do |t| + t.timestamps_with_timezone + t.bigint :project_id, null: false + t.string :key, null: false, limit: 255 + t.index %i[project_id key], unique: true + end + + create_table :ci_resources do |t| + t.timestamps_with_timezone + t.references :resource_group, null: false, index: false, foreign_key: { to_table: :ci_resource_groups, on_delete: :cascade } + t.bigint :build_id, null: true + t.index %i[build_id] + t.index %i[resource_group_id build_id], unique: true + end + end +end diff --git a/db/migrate/20191128145232_add_fk_to_ci_resources_build_id.rb b/db/migrate/20191128145232_add_fk_to_ci_resources_build_id.rb new file mode 100644 index 0000000000000000000000000000000000000000..a13513de3b2707978a32658d43645134aaf382ef --- /dev/null +++ b/db/migrate/20191128145232_add_fk_to_ci_resources_build_id.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class AddFkToCiResourcesBuildId < ActiveRecord::Migration[5.2] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_concurrent_foreign_key :ci_resources, :ci_builds, column: :build_id, on_delete: :nullify + end + + def down + remove_foreign_key_if_exists :ci_resources, column: :build_id + end +end diff --git a/db/migrate/20191128145233_add_fk_to_ci_resource_groups_project_id.rb b/db/migrate/20191128145233_add_fk_to_ci_resource_groups_project_id.rb new file mode 100644 index 0000000000000000000000000000000000000000..bb23012ea9bde33d92e4b59f893a27088e2c710e --- /dev/null +++ b/db/migrate/20191128145233_add_fk_to_ci_resource_groups_project_id.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class AddFkToCiResourceGroupsProjectId < ActiveRecord::Migration[5.2] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_concurrent_foreign_key :ci_resource_groups, :projects, column: :project_id, on_delete: :cascade + end + + def down + remove_foreign_key_if_exists :ci_resource_groups, column: :project_id + end +end diff --git a/db/migrate/20191129134844_add_broadcast_type_to_broadcast_message.rb b/db/migrate/20191129134844_add_broadcast_type_to_broadcast_message.rb index 84d17f558d1f4b8c51820337f8b25a469498f7b5..884d9ac6d7f25131ed452eac802d9b839541e91c 100644 --- a/db/migrate/20191129134844_add_broadcast_type_to_broadcast_message.rb +++ b/db/migrate/20191129134844_add_broadcast_type_to_broadcast_message.rb @@ -10,7 +10,7 @@ class AddBroadcastTypeToBroadcastMessage < ActiveRecord::Migration[5.2] disable_ddl_transaction! def up - add_column_with_default(:broadcast_messages, :broadcast_type, :smallint, default: BROADCAST_MESSAGE_BANNER_TYPE) + add_column_with_default(:broadcast_messages, :broadcast_type, :smallint, default: BROADCAST_MESSAGE_BANNER_TYPE) # rubocop:disable Migration/AddColumnWithDefault end def down diff --git a/db/migrate/20191129144630_add_resource_group_id_to_ci_builds.rb b/db/migrate/20191129144630_add_resource_group_id_to_ci_builds.rb new file mode 100644 index 0000000000000000000000000000000000000000..2e696c32e7ef6aeea1c8e8d302d0b62a2ffaa9d0 --- /dev/null +++ b/db/migrate/20191129144630_add_resource_group_id_to_ci_builds.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class AddResourceGroupIdToCiBuilds < ActiveRecord::Migration[5.2] + DOWNTIME = false + + def up + unless column_exists?(:ci_builds, :resource_group_id) + add_column :ci_builds, :resource_group_id, :bigint + end + + unless column_exists?(:ci_builds, :waiting_for_resource_at) + add_column :ci_builds, :waiting_for_resource_at, :datetime_with_timezone + end + end + + def down + if column_exists?(:ci_builds, :resource_group_id) + remove_column :ci_builds, :resource_group_id, :bigint + end + + if column_exists?(:ci_builds, :waiting_for_resource_at) + remove_column :ci_builds, :waiting_for_resource_at, :datetime_with_timezone + end + end +end diff --git a/db/migrate/20191129144631_add_index_to_resource_group_id.rb b/db/migrate/20191129144631_add_index_to_resource_group_id.rb new file mode 100644 index 0000000000000000000000000000000000000000..0e5a84f094dfc5c8e4e3359ad87cf0be6c04e932 --- /dev/null +++ b/db/migrate/20191129144631_add_index_to_resource_group_id.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class AddIndexToResourceGroupId < ActiveRecord::Migration[5.2] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + INDEX_NAME = 'index_for_resource_group'.freeze + + disable_ddl_transaction! + + def up + add_concurrent_index :ci_builds, %i[resource_group_id id], where: 'resource_group_id IS NOT NULL', name: INDEX_NAME + add_concurrent_foreign_key :ci_builds, :ci_resource_groups, column: :resource_group_id, on_delete: :nullify + end + + def down + remove_foreign_key_if_exists :ci_builds, column: :resource_group_id + remove_concurrent_index_by_name :ci_builds, INDEX_NAME + end +end diff --git a/db/migrate/20191205212923_support_multiple_milestones_for_issues.rb b/db/migrate/20191205212923_support_multiple_milestones_for_issues.rb new file mode 100644 index 0000000000000000000000000000000000000000..e0edd76c4b93f79700b1f57861303f08a3c025b0 --- /dev/null +++ b/db/migrate/20191205212923_support_multiple_milestones_for_issues.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class SupportMultipleMilestonesForIssues < ActiveRecord::Migration[5.2] + DOWNTIME = false + + def change + create_table :issue_milestones, id: false do |t| + t.references :issue, foreign_key: { on_delete: :cascade }, index: { unique: true }, null: false + t.references :milestone, foreign_key: { on_delete: :cascade }, index: true, null: false + end + + add_index :issue_milestones, [:issue_id, :milestone_id], unique: true + end +end diff --git a/db/migrate/20191205212924_support_multiple_milestones_for_merge_requests.rb b/db/migrate/20191205212924_support_multiple_milestones_for_merge_requests.rb new file mode 100644 index 0000000000000000000000000000000000000000..85ad1a748e9fee8685bbd6d3201aba9a99e202aa --- /dev/null +++ b/db/migrate/20191205212924_support_multiple_milestones_for_merge_requests.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class SupportMultipleMilestonesForMergeRequests < ActiveRecord::Migration[5.2] + DOWNTIME = false + + def change + create_table :merge_request_milestones, id: false do |t| + t.references :merge_request, foreign_key: { on_delete: :cascade }, index: { unique: true }, null: false + t.references :milestone, foreign_key: { on_delete: :cascade }, index: true, null: false + end + + add_index :merge_request_milestones, [:merge_request_id, :milestone_id], name: 'index_mrs_milestones_on_mr_id_and_milestone_id', unique: true + end +end diff --git a/db/migrate/20191207104000_add_render_whitespace_in_code_to_user_preference.rb b/db/migrate/20191207104000_add_render_whitespace_in_code_to_user_preference.rb new file mode 100644 index 0000000000000000000000000000000000000000..83b44b98c67b784cf8b28801d378eb8aa0312e67 --- /dev/null +++ b/db/migrate/20191207104000_add_render_whitespace_in_code_to_user_preference.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class AddRenderWhitespaceInCodeToUserPreference < ActiveRecord::Migration[5.1] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_column(:user_preferences, :render_whitespace_in_code, :boolean) + end + + def down + remove_column(:user_preferences, :render_whitespace_in_code) + end +end diff --git a/db/migrate/20191208110214_add_suggestion_commit_message_to_projects.rb b/db/migrate/20191208110214_add_suggestion_commit_message_to_projects.rb new file mode 100644 index 0000000000000000000000000000000000000000..c4344cf212c39c26a01adf0400632047c79a3a92 --- /dev/null +++ b/db/migrate/20191208110214_add_suggestion_commit_message_to_projects.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddSuggestionCommitMessageToProjects < ActiveRecord::Migration[5.2] + DOWNTIME = false + + def change + add_column :projects, :suggestion_commit_message, :string, limit: 255 + end +end diff --git a/db/migrate/20191210211253_create_resource_weight_event.rb b/db/migrate/20191210211253_create_resource_weight_event.rb new file mode 100644 index 0000000000000000000000000000000000000000..b458c5f169f023f4b6395fe7ce419487743ca9f3 --- /dev/null +++ b/db/migrate/20191210211253_create_resource_weight_event.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class CreateResourceWeightEvent < ActiveRecord::Migration[5.2] + DOWNTIME = false + + def change + create_table :resource_weight_events do |t| + t.references :user, null: false, foreign_key: { on_delete: :nullify }, + index: { name: 'index_resource_weight_events_on_user_id' } + t.references :issue, null: false, foreign_key: { on_delete: :cascade }, + index: false + t.integer :weight + t.datetime_with_timezone :created_at, null: false + + t.index [:issue_id, :weight], name: 'index_resource_weight_events_on_issue_id_and_weight' + end + end +end diff --git a/db/migrate/20191213120427_fix_max_pages_size.rb b/db/migrate/20191213120427_fix_max_pages_size.rb new file mode 100644 index 0000000000000000000000000000000000000000..498ea91b773ab4cd8a092771c979ac809f38ea30 --- /dev/null +++ b/db/migrate/20191213120427_fix_max_pages_size.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class FixMaxPagesSize < ActiveRecord::Migration[5.2] + DOWNTIME = false + MAX_SIZE = 1.terabyte / 1.megabyte + + class ApplicationSetting < ActiveRecord::Base + self.table_name = 'application_settings' + self.inheritance_column = :_type_disabled + end + + def up + table = ApplicationSetting.arel_table + ApplicationSetting.where(table[:max_pages_size].gt(MAX_SIZE)).update_all(max_pages_size: MAX_SIZE) + end + + def down + # no-op + end +end diff --git a/db/migrate/20191213143656_create_ci_pipelines_config.rb b/db/migrate/20191213143656_create_ci_pipelines_config.rb new file mode 100644 index 0000000000000000000000000000000000000000..92636f35d01d667a10485f1560a5057bffa3ffd1 --- /dev/null +++ b/db/migrate/20191213143656_create_ci_pipelines_config.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class CreateCiPipelinesConfig < ActiveRecord::Migration[5.2] + DOWNTIME = false + + def change + create_table :ci_pipelines_config, id: false do |t| + t.references :pipeline, + primary_key: true, + foreign_key: { to_table: :ci_pipelines, on_delete: :cascade } + t.text :content, null: false + end + end +end diff --git a/db/migrate/20191213184609_backfill_operations_feature_flags_active.rb b/db/migrate/20191213184609_backfill_operations_feature_flags_active.rb new file mode 100644 index 0000000000000000000000000000000000000000..cc61b30acae71cdb949899573c7915330e6bd58d --- /dev/null +++ b/db/migrate/20191213184609_backfill_operations_feature_flags_active.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class BackfillOperationsFeatureFlagsActive < ActiveRecord::Migration[5.2] + DOWNTIME = false + + disable_ddl_transaction! + + class OperationsFeatureFlag < ActiveRecord::Base + self.table_name = 'operations_feature_flags' + self.inheritance_column = :_type_disabled + end + + def up + OperationsFeatureFlag.where(active: false).update_all(active: true) + end + + def down + # no-op + end +end diff --git a/db/migrate/20191216074800_add_epic_date_sourcing_milestone_indexes.rb b/db/migrate/20191216074800_add_epic_date_sourcing_milestone_indexes.rb new file mode 100644 index 0000000000000000000000000000000000000000..72fd593733193637c8e400dbb89f68927559f80f --- /dev/null +++ b/db/migrate/20191216074800_add_epic_date_sourcing_milestone_indexes.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +class AddEpicDateSourcingMilestoneIndexes < ActiveRecord::Migration[5.2] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_concurrent_index :epics, due_date_column + add_concurrent_index :epics, start_date_column + end + + def down + remove_concurrent_index :epics, start_date_column + remove_concurrent_index :epics, due_date_column + end + + private + + def due_date_column + :due_date_sourcing_milestone_id + end + + def start_date_column + :start_date_sourcing_milestone_id + end +end diff --git a/db/migrate/20191216074802_add_epic_start_date_sourcing_milestone_id_foreign_key.rb b/db/migrate/20191216074802_add_epic_start_date_sourcing_milestone_id_foreign_key.rb new file mode 100644 index 0000000000000000000000000000000000000000..1c0713ec5864e4187034168aadd46588db2c7582 --- /dev/null +++ b/db/migrate/20191216074802_add_epic_start_date_sourcing_milestone_id_foreign_key.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class AddEpicStartDateSourcingMilestoneIdForeignKey < ActiveRecord::Migration[5.2] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_concurrent_foreign_key :epics, :milestones, column: start_date_column, on_delete: :nullify, validate: false + end + + def down + remove_foreign_key_if_exists :epics, column: start_date_column + end + + private + + def start_date_column + :start_date_sourcing_milestone_id + end +end diff --git a/db/migrate/20191216074803_add_epic_due_date_sourcing_milestone_id_foreign_key.rb b/db/migrate/20191216074803_add_epic_due_date_sourcing_milestone_id_foreign_key.rb new file mode 100644 index 0000000000000000000000000000000000000000..51202e358cc326b9ed9fcc6cfdbfcb2eae5bacc2 --- /dev/null +++ b/db/migrate/20191216074803_add_epic_due_date_sourcing_milestone_id_foreign_key.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class AddEpicDueDateSourcingMilestoneIdForeignKey < ActiveRecord::Migration[5.2] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_concurrent_foreign_key :epics, :milestones, column: due_date_column, on_delete: :nullify, validate: false + end + + def down + remove_foreign_key_if_exists :epics, column: due_date_column + end + + private + + def due_date_column + :due_date_sourcing_milestone_id + end +end diff --git a/db/migrate/20191217212348_add_modsecurity_enabled_to_ingress_application.rb b/db/migrate/20191217212348_add_modsecurity_enabled_to_ingress_application.rb new file mode 100644 index 0000000000000000000000000000000000000000..2690a5762dd4fdfec88f5c6c7dd5c2058ccdfa4b --- /dev/null +++ b/db/migrate/20191217212348_add_modsecurity_enabled_to_ingress_application.rb @@ -0,0 +1,16 @@ +# 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 AddModsecurityEnabledToIngressApplication < ActiveRecord::Migration[5.2] + DOWNTIME = false + + def up + add_column :clusters_applications_ingress, :modsecurity_enabled, :boolean + end + + def down + remove_column :clusters_applications_ingress, :modsecurity_enabled + end +end diff --git a/db/migrate/20191218084115_add_updating_name_disabled_for_users_to_application_settings.rb b/db/migrate/20191218084115_add_updating_name_disabled_for_users_to_application_settings.rb new file mode 100644 index 0000000000000000000000000000000000000000..eb9d4ace5b48c99cd651cf8275614e3ce7e6568d --- /dev/null +++ b/db/migrate/20191218084115_add_updating_name_disabled_for_users_to_application_settings.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class AddUpdatingNameDisabledForUsersToApplicationSettings < ActiveRecord::Migration[5.2] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_column_with_default(:application_settings, :updating_name_disabled_for_users, + :boolean, + default: false, + allow_null: false) + end + + def down + remove_column(:application_settings, :updating_name_disabled_for_users) + end +end diff --git a/db/migrate/20191218122457_add_force_pages_access_control_to_application_settings.rb b/db/migrate/20191218122457_add_force_pages_access_control_to_application_settings.rb new file mode 100644 index 0000000000000000000000000000000000000000..97352fc98ff64db57d459d662ac0daae5bfb66b3 --- /dev/null +++ b/db/migrate/20191218122457_add_force_pages_access_control_to_application_settings.rb @@ -0,0 +1,12 @@ +# 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 AddForcePagesAccessControlToApplicationSettings < ActiveRecord::Migration[5.2] + DOWNTIME = false + + def change + add_column :application_settings, :force_pages_access_control, :boolean, null: false, default: false + end +end diff --git a/db/migrate/20191218124915_add_repository_storage_to_snippets.rb b/db/migrate/20191218124915_add_repository_storage_to_snippets.rb new file mode 100644 index 0000000000000000000000000000000000000000..df9a9d2ff439e932e5a106bac6ef8854c608e33d --- /dev/null +++ b/db/migrate/20191218124915_add_repository_storage_to_snippets.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class AddRepositoryStorageToSnippets < ActiveRecord::Migration[5.2] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_column_with_default( # rubocop:disable Migration/AddColumnWithDefault + :snippets, + :repository_storage, + :string, + default: 'default', + limit: 255, + allow_null: false + ) + end + + def down + remove_column(:snippets, :repository_storage) + end +end diff --git a/db/migrate/20191218125015_add_storage_version_to_snippets.rb b/db/migrate/20191218125015_add_storage_version_to_snippets.rb new file mode 100644 index 0000000000000000000000000000000000000000..b1bd3589692c367c6ca258878ee45dc9fa450b6b --- /dev/null +++ b/db/migrate/20191218125015_add_storage_version_to_snippets.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class AddStorageVersionToSnippets < ActiveRecord::Migration[5.2] + include Gitlab::Database::MigrationHelpers + + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + disable_ddl_transaction! + + def up + add_column_with_default( # rubocop:disable Migration/AddColumnWithDefault + :snippets, + :storage_version, + :integer, + default: 2, + allow_null: false + ) + end + + def down + remove_column(:snippets, :storage_version) + end +end diff --git a/db/migrate/20191225071320_add_index_to_elasticsearch_indexed_namespaces.rb b/db/migrate/20191225071320_add_index_to_elasticsearch_indexed_namespaces.rb new file mode 100644 index 0000000000000000000000000000000000000000..758838cb7754c23363ba9692fc1653529eb3598f --- /dev/null +++ b/db/migrate/20191225071320_add_index_to_elasticsearch_indexed_namespaces.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class AddIndexToElasticsearchIndexedNamespaces < ActiveRecord::Migration[5.2] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_concurrent_index(:elasticsearch_indexed_namespaces, :created_at) + end + + def down + remove_concurrent_index(:elasticsearch_indexed_namespaces, :created_at) + end +end diff --git a/db/migrate/20191227140254_update_personal_access_tokens_user_id_foreign_key.rb b/db/migrate/20191227140254_update_personal_access_tokens_user_id_foreign_key.rb new file mode 100644 index 0000000000000000000000000000000000000000..fbf17b28274d59e5c52ebedc31796b23e17a06ad --- /dev/null +++ b/db/migrate/20191227140254_update_personal_access_tokens_user_id_foreign_key.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class UpdatePersonalAccessTokensUserIdForeignKey < ActiveRecord::Migration[5.2] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + CONSTRAINT_NAME = 'fk_personal_access_tokens_user_id' + + disable_ddl_transaction! + + def up + add_concurrent_foreign_key(:personal_access_tokens, :users, column: :user_id, on_delete: :cascade, name: CONSTRAINT_NAME) + remove_foreign_key_if_exists(:personal_access_tokens, column: :user_id, on_delete: nil) + end + + def down + add_concurrent_foreign_key(:personal_access_tokens, :users, column: :user_id, on_delete: nil) + remove_foreign_key_if_exists(:personal_access_tokens, column: :user_id, on_delete: :cascade, name: CONSTRAINT_NAME) + end +end diff --git a/db/migrate/20191229140154_drop_index_ci_pipelines_on_project_id.rb b/db/migrate/20191229140154_drop_index_ci_pipelines_on_project_id.rb new file mode 100644 index 0000000000000000000000000000000000000000..dbfe3758cda2a65c47d4b6a0224d667cf2663091 --- /dev/null +++ b/db/migrate/20191229140154_drop_index_ci_pipelines_on_project_id.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class DropIndexCiPipelinesOnProjectId < ActiveRecord::Migration[5.2] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + remove_concurrent_index :ci_pipelines, :project_id + end + + def down + add_concurrent_index :ci_pipelines, :project_id + end +end diff --git a/db/migrate/20200102170221_add_storage_version_index_to_projects.rb b/db/migrate/20200102170221_add_storage_version_index_to_projects.rb new file mode 100644 index 0000000000000000000000000000000000000000..8965b5eedb909e8a0b37b3de4bc7967e08546dc6 --- /dev/null +++ b/db/migrate/20200102170221_add_storage_version_index_to_projects.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class AddStorageVersionIndexToProjects < ActiveRecord::Migration[5.2] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_concurrent_index :projects, :id, where: 'storage_version < 2 or storage_version IS NULL', name: 'index_on_id_partial_with_legacy_storage' + end + + def down + remove_concurrent_index :projects, :id, where: 'storage_version < 2 or storage_version IS NULL', name: 'index_on_id_partial_with_legacy_storage' + end +end diff --git a/db/migrate/20200103190741_add_column_for_instance_administrators_group.rb b/db/migrate/20200103190741_add_column_for_instance_administrators_group.rb new file mode 100644 index 0000000000000000000000000000000000000000..5afc7dc6a4d4816c750fb1c8e2927be6e9cf164c --- /dev/null +++ b/db/migrate/20200103190741_add_column_for_instance_administrators_group.rb @@ -0,0 +1,12 @@ +# 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 AddColumnForInstanceAdministratorsGroup < ActiveRecord::Migration[5.2] + DOWNTIME = false + + def change + add_column :application_settings, :instance_administrators_group_id, :integer + end +end diff --git a/db/migrate/20200103192859_add_fk_for_instance_administrators_group.rb b/db/migrate/20200103192859_add_fk_for_instance_administrators_group.rb new file mode 100644 index 0000000000000000000000000000000000000000..0ad37d6bd29b727c0d97329b074324ee438a5789 --- /dev/null +++ b/db/migrate/20200103192859_add_fk_for_instance_administrators_group.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class AddFkForInstanceAdministratorsGroup < ActiveRecord::Migration[5.2] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_concurrent_foreign_key( + :application_settings, + :namespaces, + column: :instance_administrators_group_id, + on_delete: :nullify + ) + end + + def down + remove_foreign_key :application_settings, column: :instance_administrators_group_id + end +end diff --git a/db/migrate/20200103192914_add_index_for_instance_administrators_group.rb b/db/migrate/20200103192914_add_index_for_instance_administrators_group.rb new file mode 100644 index 0000000000000000000000000000000000000000..703dcf5d0e35348516fe7da438630c470d1bc32e --- /dev/null +++ b/db/migrate/20200103192914_add_index_for_instance_administrators_group.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class AddIndexForInstanceAdministratorsGroup < ActiveRecord::Migration[5.2] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_concurrent_index :application_settings, :instance_administrators_group_id + end + + def down + remove_concurrent_index :application_settings, :instance_administrators_group_id + end +end diff --git a/db/migrate/20200103195205_add_autoclose_referenced_issues_to_projects.rb b/db/migrate/20200103195205_add_autoclose_referenced_issues_to_projects.rb new file mode 100644 index 0000000000000000000000000000000000000000..ac1aa2276fc5c51510b52bc373ceef74c4dfd5ae --- /dev/null +++ b/db/migrate/20200103195205_add_autoclose_referenced_issues_to_projects.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddAutocloseReferencedIssuesToProjects < ActiveRecord::Migration[5.2] + DOWNTIME = false + + def change + add_column :projects, :autoclose_referenced_issues, :boolean + end +end diff --git a/db/migrate/20200104113850_add_forking_access_level_to_project_feature.rb b/db/migrate/20200104113850_add_forking_access_level_to_project_feature.rb new file mode 100644 index 0000000000000000000000000000000000000000..2c5f764ebf3d52d8b26efc0d8a6da353252ba59d --- /dev/null +++ b/db/migrate/20200104113850_add_forking_access_level_to_project_feature.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddForkingAccessLevelToProjectFeature < ActiveRecord::Migration[5.2] + DOWNTIME = false + + def change + add_column :project_features, :forking_access_level, :integer + end +end diff --git a/db/migrate/20200106085831_add_timestamps_to_packages_tags.rb b/db/migrate/20200106085831_add_timestamps_to_packages_tags.rb new file mode 100644 index 0000000000000000000000000000000000000000..2720d9b3297ff4f91f7ef129b1132c6d1b0643e3 --- /dev/null +++ b/db/migrate/20200106085831_add_timestamps_to_packages_tags.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class AddTimestampsToPackagesTags < ActiveRecord::Migration[5.2] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + # We disable these cops here because adding this column is safe. The table does not + # have any data in it. + # rubocop: disable Migration/AddIndex + def up + add_timestamps_with_timezone(:packages_tags, null: false) + add_index(:packages_tags, [:package_id, :updated_at], order: { updated_at: :desc }) + end + + # We disable these cops here because adding this column is safe. The table does not + # have any data in it. + # rubocop: disable Migration/RemoveIndex + def down + remove_index(:packages_tags, [:package_id, :updated_at]) + remove_timestamps(:packages_tags) + end +end diff --git a/db/migrate/20200107172020_add_timestamp_softwarelicensespolicy.rb b/db/migrate/20200107172020_add_timestamp_softwarelicensespolicy.rb new file mode 100644 index 0000000000000000000000000000000000000000..0d62f3051ce9c80669a9b35659f025d6effddd5b --- /dev/null +++ b/db/migrate/20200107172020_add_timestamp_softwarelicensespolicy.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class AddTimestampSoftwarelicensespolicy < ActiveRecord::Migration[5.2] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def up + add_timestamps_with_timezone(:software_license_policies, null: true) + end + + def down + remove_timestamps(:software_license_policies) + end +end diff --git a/db/migrate/20200108100603_update_project_hooks_limit.rb b/db/migrate/20200108100603_update_project_hooks_limit.rb new file mode 100644 index 0000000000000000000000000000000000000000..91533b69afa9d5fd02ad438d3f09b84a703ecf35 --- /dev/null +++ b/db/migrate/20200108100603_update_project_hooks_limit.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class UpdateProjectHooksLimit < ActiveRecord::Migration[5.2] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def up + return unless Gitlab.com? + + create_or_update_plan_limit('project_hooks', 'free', 100) + create_or_update_plan_limit('project_hooks', 'bronze', 100) + create_or_update_plan_limit('project_hooks', 'silver', 100) + end + + def down + return unless Gitlab.com? + + create_or_update_plan_limit('project_hooks', 'free', 10) + create_or_update_plan_limit('project_hooks', 'bronze', 20) + create_or_update_plan_limit('project_hooks', 'silver', 30) + end +end diff --git a/db/migrate/20200108155731_create_indexes_for_project_api_created_at_order.rb b/db/migrate/20200108155731_create_indexes_for_project_api_created_at_order.rb new file mode 100644 index 0000000000000000000000000000000000000000..2356f08b6814c793e43d38721658e2d0e9095551 --- /dev/null +++ b/db/migrate/20200108155731_create_indexes_for_project_api_created_at_order.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class CreateIndexesForProjectApiCreatedAtOrder < ActiveRecord::Migration[5.2] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_concurrent_index :projects, %i(visibility_level created_at id), order: { id: :desc }, name: 'index_projects_on_visibility_level_created_at_id_desc' + add_concurrent_index :projects, %i(visibility_level created_at id), order: { created_at: :desc, id: :desc }, name: 'index_projects_on_visibility_level_created_at_desc_id_desc' + remove_concurrent_index_by_name :projects, 'index_projects_on_visibility_level_and_created_at_and_id' + end + + def down + add_concurrent_index :projects, %i(visibility_level created_at id), name: 'index_projects_on_visibility_level_and_created_at_and_id' + remove_concurrent_index_by_name :projects, 'index_projects_on_visibility_level_created_at_id_desc' + remove_concurrent_index_by_name :projects, 'index_projects_on_visibility_level_created_at_desc_id_desc' + end +end diff --git a/db/migrate/20200108233040_remove_index_project_mirror_data_on_jid.rb b/db/migrate/20200108233040_remove_index_project_mirror_data_on_jid.rb new file mode 100644 index 0000000000000000000000000000000000000000..86a69275cb713e4f2f8435245981453d660d6143 --- /dev/null +++ b/db/migrate/20200108233040_remove_index_project_mirror_data_on_jid.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class RemoveIndexProjectMirrorDataOnJid < ActiveRecord::Migration[5.2] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + remove_concurrent_index :project_mirror_data, :jid + end + + def down + add_concurrent_index :project_mirror_data, :jid + end +end diff --git a/db/migrate/20200109085206_create_approval_project_rules_protected_branches.rb b/db/migrate/20200109085206_create_approval_project_rules_protected_branches.rb new file mode 100644 index 0000000000000000000000000000000000000000..4e75f7d41fdf09116df08941928993aa3ddaf895 --- /dev/null +++ b/db/migrate/20200109085206_create_approval_project_rules_protected_branches.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class CreateApprovalProjectRulesProtectedBranches < ActiveRecord::Migration[5.2] + DOWNTIME = false + + def change + create_table :approval_project_rules_protected_branches, id: false do |t| + t.references :approval_project_rule, + null: false, + index: false, + foreign_key: { on_delete: :cascade } + t.references :protected_branch, + null: false, + index: { name: 'index_approval_project_rules_protected_branches_pb_id' }, + foreign_key: { on_delete: :cascade } + t.index [:approval_project_rule_id, :protected_branch_id], name: 'index_approval_project_rules_protected_branches_unique', unique: true, using: :btree + end + end +end diff --git a/db/migrate/20200110089001_fix_invalid_epic_sourcing_milestone_ids.rb b/db/migrate/20200110089001_fix_invalid_epic_sourcing_milestone_ids.rb new file mode 100644 index 0000000000000000000000000000000000000000..04f65c3a810d28f8f630b77a063e7ebca825d118 --- /dev/null +++ b/db/migrate/20200110089001_fix_invalid_epic_sourcing_milestone_ids.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class FixInvalidEpicSourcingMilestoneIds < ActiveRecord::Migration[5.2] + DOWNTIME = false + + def up + nullify_invalid_data(:start_date_sourcing_milestone_id) + nullify_invalid_data(:due_date_sourcing_milestone_id) + end + + def down + # no-op + end + + private + + def nullify_invalid_data(column_name) + execute(<<-SQL.squish) + UPDATE epics + SET #{column_name} = null + WHERE #{column_name} NOT IN (SELECT id FROM milestones); + SQL + end +end diff --git a/db/migrate/20200110090153_validate_foreign_key_epic_start_date_sourcing_milestone.rb b/db/migrate/20200110090153_validate_foreign_key_epic_start_date_sourcing_milestone.rb new file mode 100644 index 0000000000000000000000000000000000000000..26ddf44cfeb9d92db28ec4277244b32b0b4bdae1 --- /dev/null +++ b/db/migrate/20200110090153_validate_foreign_key_epic_start_date_sourcing_milestone.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class ValidateForeignKeyEpicStartDateSourcingMilestone < ActiveRecord::Migration[5.2] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def up + validate_foreign_key(:epics, :start_date_sourcing_milestone_id) + end + + def down + # no-op + end +end diff --git a/db/migrate/20200110144316_add_indexes_for_projects_api.rb b/db/migrate/20200110144316_add_indexes_for_projects_api.rb new file mode 100644 index 0000000000000000000000000000000000000000..6b0ca2524561a47b43043c4121cf2a9185f817fa --- /dev/null +++ b/db/migrate/20200110144316_add_indexes_for_projects_api.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +class AddIndexesForProjectsApi < ActiveRecord::Migration[5.2] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + COLUMNS = %i(created_at last_activity_at updated_at name path) + + def up + COLUMNS.each do |column| + add_concurrent_index :projects, [column, :id], where: 'visibility_level = 20', order: { id: :desc }, name: "index_projects_api_vis20_#{column}_id_desc" + add_concurrent_index :projects, [column, :id], where: 'visibility_level = 20', name: "index_projects_api_vis20_#{column}" + end + + remove_concurrent_index_by_name :projects, 'index_projects_on_visibility_level_created_at_id_desc' + remove_concurrent_index_by_name :projects, 'index_projects_on_visibility_level_created_at_desc_id_desc' + end + + def down + add_concurrent_index :projects, %i(visibility_level created_at id), order: { id: :desc }, name: 'index_projects_on_visibility_level_created_at_id_desc' + add_concurrent_index :projects, %i(visibility_level created_at id), order: { created_at: :desc, id: :desc }, name: 'index_projects_on_visibility_level_created_at_desc_id_desc' + + COLUMNS.each do |column| + remove_concurrent_index_by_name :projects, "index_projects_api_vis20_#{column}_id_desc" + remove_concurrent_index_by_name :projects, "index_projects_api_vis20_#{column}" + end + end +end diff --git a/db/migrate/20200110203532_validate_foreign_key_epic_due_date_sourcing_milestone.rb b/db/migrate/20200110203532_validate_foreign_key_epic_due_date_sourcing_milestone.rb new file mode 100644 index 0000000000000000000000000000000000000000..2ff487150687c447c52e8120374b386dc1ac47cb --- /dev/null +++ b/db/migrate/20200110203532_validate_foreign_key_epic_due_date_sourcing_milestone.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class ValidateForeignKeyEpicDueDateSourcingMilestone < ActiveRecord::Migration[5.2] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def up + validate_foreign_key(:epics, :due_date_sourcing_milestone_id) + end + + def down + # no-op + end +end diff --git a/db/migrate/20200113133352_add_indexes_for_projects_api_authenticated.rb b/db/migrate/20200113133352_add_indexes_for_projects_api_authenticated.rb new file mode 100644 index 0000000000000000000000000000000000000000..53d317b332169d590648c007d0ebede317985d29 --- /dev/null +++ b/db/migrate/20200113133352_add_indexes_for_projects_api_authenticated.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +class AddIndexesForProjectsApiAuthenticated < ActiveRecord::Migration[5.2] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + COLUMNS = %i(updated_at name) + + def up + add_concurrent_index :projects, %i(created_at id), order: { id: :desc }, name: 'index_projects_api_created_at_id_desc' + + add_concurrent_index :projects, %i(last_activity_at id), name: 'index_projects_on_last_activity_at_and_id' + remove_concurrent_index :projects, :last_activity_at + add_concurrent_index :projects, %i(last_activity_at id), order: { id: :desc }, name: 'index_projects_api_last_activity_at_id_desc' + + add_concurrent_index :projects, %i(path id), name: 'index_projects_on_path_and_id' + remove_concurrent_index_by_name :projects, 'index_projects_on_path' + add_concurrent_index :projects, %i(path id), order: { id: :desc }, name: 'index_projects_api_path_id_desc' + + COLUMNS.each do |column| + add_concurrent_index :projects, [column, :id], name: "index_projects_on_#{column}_and_id" + add_concurrent_index :projects, [column, :id], order: { id: :desc }, name: "index_projects_api_#{column}_id_desc" + end + end + + def down + remove_concurrent_index_by_name :projects, 'index_projects_api_created_at_id_desc' + + remove_concurrent_index_by_name :projects, 'index_projects_on_last_activity_at_and_id' + add_concurrent_index :projects, :last_activity_at, name: 'index_projects_on_last_activity_at' + remove_concurrent_index_by_name :projects, 'index_projects_api_last_activity_at_id_desc' + + remove_concurrent_index_by_name :projects, 'index_projects_on_path_and_id' + add_concurrent_index :projects, :path, name: 'index_projects_on_path' + remove_concurrent_index_by_name :projects, 'index_projects_api_path_id_desc' + + COLUMNS.each do |column| + remove_concurrent_index_by_name :projects, "index_projects_on_#{column}_and_id" + remove_concurrent_index_by_name :projects, "index_projects_api_#{column}_id_desc" + end + end +end diff --git a/db/migrate/20200114204949_add_index_to_sentry_issues_sentry_issue_identifier.rb b/db/migrate/20200114204949_add_index_to_sentry_issues_sentry_issue_identifier.rb new file mode 100644 index 0000000000000000000000000000000000000000..05b9e6d59589989ee8e0ecb5a6df49799fb507bf --- /dev/null +++ b/db/migrate/20200114204949_add_index_to_sentry_issues_sentry_issue_identifier.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class AddIndexToSentryIssuesSentryIssueIdentifier < ActiveRecord::Migration[5.2] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_concurrent_index :sentry_issues, :sentry_issue_identifier + end + + def down + remove_concurrent_index :sentry_issues, :sentry_issue_identifier + end +end diff --git a/db/migrate/20200115135132_add_retry_count_and_group_id_to_import_failures.rb b/db/migrate/20200115135132_add_retry_count_and_group_id_to_import_failures.rb new file mode 100644 index 0000000000000000000000000000000000000000..8f62aaba60efed47e8303c1d0a92f8aafdd56383 --- /dev/null +++ b/db/migrate/20200115135132_add_retry_count_and_group_id_to_import_failures.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class AddRetryCountAndGroupIdToImportFailures < ActiveRecord::Migration[5.2] + DOWNTIME = false + + def change + add_column :import_failures, :retry_count, :integer + add_column :import_failures, :group_id, :integer + change_column_null(:import_failures, :project_id, true) + end +end diff --git a/db/migrate/20200115135234_add_group_index_and_fk_to_import_failures.rb b/db/migrate/20200115135234_add_group_index_and_fk_to_import_failures.rb new file mode 100644 index 0000000000000000000000000000000000000000..ae0d6d31c42de0ba39e69d4dda2deb65552356d2 --- /dev/null +++ b/db/migrate/20200115135234_add_group_index_and_fk_to_import_failures.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class AddGroupIndexAndFkToImportFailures < ActiveRecord::Migration[5.2] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + GROUP_INDEX = 'index_import_failures_on_group_id_not_null'.freeze + + disable_ddl_transaction! + + def up + add_concurrent_index(:import_failures, :group_id, where: 'group_id IS NOT NULL', name: GROUP_INDEX) + + add_concurrent_foreign_key(:import_failures, :namespaces, column: :group_id) + end + + def down + remove_foreign_key(:import_failures, column: :group_id) + + remove_concurrent_index_by_name(:import_failures, GROUP_INDEX) + end +end diff --git a/db/migrate/20200117112554_update_project_index_to_import_failures.rb b/db/migrate/20200117112554_update_project_index_to_import_failures.rb new file mode 100644 index 0000000000000000000000000000000000000000..1e8347aad44e15f93ff50d10764d1bc8dde662fa --- /dev/null +++ b/db/migrate/20200117112554_update_project_index_to_import_failures.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class UpdateProjectIndexToImportFailures < ActiveRecord::Migration[5.2] + include Gitlab::Database::MigrationHelpers + + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + PROJECT_INDEX_OLD = 'index_import_failures_on_project_id'.freeze + PROJECT_INDEX_NEW = 'index_import_failures_on_project_id_not_null'.freeze + + disable_ddl_transaction! + + def up + add_concurrent_index(:import_failures, :project_id, where: 'project_id IS NOT NULL', name: PROJECT_INDEX_NEW) + remove_concurrent_index_by_name(:import_failures, PROJECT_INDEX_OLD) + end + + def down + add_concurrent_index(:import_failures, :project_id, name: PROJECT_INDEX_OLD) + remove_concurrent_index_by_name(:import_failures, PROJECT_INDEX_NEW) + end +end diff --git a/db/post_migrate/20190924152703_migrate_issue_trackers_data.rb b/db/post_migrate/20190924152703_migrate_issue_trackers_data.rb new file mode 100644 index 0000000000000000000000000000000000000000..93ea501e7e0ba0c0238e0755e8f8928bc5cc8c8e --- /dev/null +++ b/db/post_migrate/20190924152703_migrate_issue_trackers_data.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +class MigrateIssueTrackersData < ActiveRecord::Migration[5.1] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + INTERVAL = 3.minutes.to_i + BATCH_SIZE = 5_000 + MIGRATION = 'MigrateIssueTrackersSensitiveData' + + disable_ddl_transaction! + + class Service < ActiveRecord::Base + self.table_name = 'services' + self.inheritance_column = :_type_disabled + + include ::EachBatch + end + + def up + relation = Service.where(category: 'issue_tracker').where("properties IS NOT NULL AND properties != '{}' AND properties != ''") + queue_background_migration_jobs_by_range_at_intervals(relation, + MIGRATION, + INTERVAL, + batch_size: BATCH_SIZE) + end + + def down + # no need + end +end diff --git a/db/post_migrate/20191114204343_remove_milestone_id_from_epics.rb b/db/post_migrate/20191114204343_remove_milestone_id_from_epics.rb new file mode 100644 index 0000000000000000000000000000000000000000..4ef6decda95ba9615382196ef535e94acc567065 --- /dev/null +++ b/db/post_migrate/20191114204343_remove_milestone_id_from_epics.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class RemoveMilestoneIdFromEpics < ActiveRecord::Migration[5.2] + DOWNTIME = false + + def up + remove_column :epics, :milestone_id + end + + def down + add_column :epics, :milestone_id, :integer + end +end diff --git a/db/post_migrate/20191128162854_drop_project_ci_cd_settings_merge_trains_enabled.rb b/db/post_migrate/20191128162854_drop_project_ci_cd_settings_merge_trains_enabled.rb new file mode 100644 index 0000000000000000000000000000000000000000..df5c6c8f6cca6273ecba4a62746efa9ae1f8dc22 --- /dev/null +++ b/db/post_migrate/20191128162854_drop_project_ci_cd_settings_merge_trains_enabled.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class DropProjectCiCdSettingsMergeTrainsEnabled < ActiveRecord::Migration[5.2] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + remove_column :project_ci_cd_settings, :merge_trains_enabled + end + + def down + add_column_with_default :project_ci_cd_settings, :merge_trains_enabled, :boolean, default: false, allow_null: true + end +end diff --git a/db/post_migrate/20191204114127_delete_legacy_triggers.rb b/db/post_migrate/20191204114127_delete_legacy_triggers.rb new file mode 100644 index 0000000000000000000000000000000000000000..82d901ae68989bb2ac48d9cd7ce9a919c2335cfe --- /dev/null +++ b/db/post_migrate/20191204114127_delete_legacy_triggers.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class DeleteLegacyTriggers < ActiveRecord::Migration[5.2] + DOWNTIME = false + + def up + execute <<~SQL + DELETE FROM ci_triggers WHERE owner_id IS NULL + SQL + + change_column_null :ci_triggers, :owner_id, false + end + + def down + change_column_null :ci_triggers, :owner_id, true + end +end diff --git a/db/post_migrate/20191218225624_add_index_on_project_id_to_ci_pipelines.rb b/db/post_migrate/20191218225624_add_index_on_project_id_to_ci_pipelines.rb new file mode 100644 index 0000000000000000000000000000000000000000..ab6c3b0616ad58f13978463705edcd8fe7a93e98 --- /dev/null +++ b/db/post_migrate/20191218225624_add_index_on_project_id_to_ci_pipelines.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class AddIndexOnProjectIdToCiPipelines < ActiveRecord::Migration[5.2] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + INDEX_NAME = 'index_ci_pipelines_on_project_id_and_id_desc' + + disable_ddl_transaction! + + def up + add_concurrent_index :ci_pipelines, [:project_id, :id], name: INDEX_NAME, order: { id: :desc } + end + + def down + remove_concurrent_index :ci_pipelines, [:project_id, :id], name: INDEX_NAME, order: { id: :desc } + end +end diff --git a/db/post_migrate/20200106071113_update_fingerprint_sha256_within_keys.rb b/db/post_migrate/20200106071113_update_fingerprint_sha256_within_keys.rb new file mode 100644 index 0000000000000000000000000000000000000000..06f5c8810068815825e7f3258fe39f4f7ad6da4c --- /dev/null +++ b/db/post_migrate/20200106071113_update_fingerprint_sha256_within_keys.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class UpdateFingerprintSha256WithinKeys < ActiveRecord::Migration[5.0] + include Gitlab::Database::MigrationHelpers + + class Key < ActiveRecord::Base + include EachBatch + + self.table_name = 'keys' + self.inheritance_column = :_type_disabled + end + + disable_ddl_transaction! + + def up + queue_background_migration_jobs_by_range_at_intervals(Key, 'MigrateFingerprintSha256WithinKeys', 5.minutes) + end + + def down + # no-op + end +end diff --git a/db/post_migrate/20200113151354_remove_creations_in_gitlab_subscription_histories.rb b/db/post_migrate/20200113151354_remove_creations_in_gitlab_subscription_histories.rb new file mode 100644 index 0000000000000000000000000000000000000000..39ca5124b324a74e049f37d0f50ead4d61b016c8 --- /dev/null +++ b/db/post_migrate/20200113151354_remove_creations_in_gitlab_subscription_histories.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class RemoveCreationsInGitlabSubscriptionHistories < ActiveRecord::Migration[5.2] + DOWNTIME = false + GITLAB_SUBSCRIPTION_CREATED = 0 + + def up + return unless Gitlab.com? + + delete_sql = "DELETE FROM gitlab_subscription_histories WHERE change_type=#{GITLAB_SUBSCRIPTION_CREATED} RETURNING *" + + records = execute(delete_sql) + + logger = Gitlab::BackgroundMigration::Logger.build + records.to_a.each do |record| + logger.info record.as_json.merge(message: "gitlab_subscription_histories with change_type=0 was deleted") + end + end + + def down + # There's no way to restore, and the data is useless + # all the data to be deleted in case needed https://gitlab.com/gitlab-org/gitlab/uploads/7409379b0ed658624f5d33202b5668a1/gitlab_subscription_histories_change_type_0.sql.txt + end +end diff --git a/db/post_migrate/20200114112932_add_temporary_partial_index_on_project_id_to_services.rb b/db/post_migrate/20200114112932_add_temporary_partial_index_on_project_id_to_services.rb new file mode 100644 index 0000000000000000000000000000000000000000..55494f1e4ac89226eb40dee1a0ccbade965f10bd --- /dev/null +++ b/db/post_migrate/20200114112932_add_temporary_partial_index_on_project_id_to_services.rb @@ -0,0 +1,22 @@ +# 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 AddTemporaryPartialIndexOnProjectIdToServices < ActiveRecord::Migration[5.2] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + INDEX_NAME = 'tmp_index_on_project_id_partial_with_prometheus_services' + PARTIAL_FILTER = "type = 'PrometheusService'" + + disable_ddl_transaction! + + def up + add_concurrent_index :services, :project_id, where: PARTIAL_FILTER, name: INDEX_NAME + end + + def down + remove_concurrent_index :services, :project_id, where: PARTIAL_FILTER, name: INDEX_NAME + end +end diff --git a/db/post_migrate/20200114113341_patch_prometheus_services_for_shared_cluster_applications.rb b/db/post_migrate/20200114113341_patch_prometheus_services_for_shared_cluster_applications.rb new file mode 100644 index 0000000000000000000000000000000000000000..68361f7b176b00e170ca1f5b0fd4548877ebb2a2 --- /dev/null +++ b/db/post_migrate/20200114113341_patch_prometheus_services_for_shared_cluster_applications.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +class PatchPrometheusServicesForSharedClusterApplications < ActiveRecord::Migration[5.2] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + MIGRATION = 'ActivatePrometheusServicesForSharedClusterApplications'.freeze + BATCH_SIZE = 500 + DELAY = 2.minutes + + disable_ddl_transaction! + + module Migratable + module Applications + class Prometheus < ActiveRecord::Base + self.table_name = 'clusters_applications_prometheus' + + enum status: { + errored: -1, + installed: 3, + updated: 5 + } + end + end + + class Project < ActiveRecord::Base + self.table_name = 'projects' + include ::EachBatch + + scope :with_application_on_group_clusters, -> { + joins("INNER JOIN namespaces ON namespaces.id = projects.namespace_id") + .joins("INNER JOIN cluster_groups ON cluster_groups.group_id = namespaces.id") + .joins("INNER JOIN clusters ON clusters.id = cluster_groups.cluster_id AND clusters.cluster_type = #{Cluster.cluster_types['group_type']}") + .joins("INNER JOIN clusters_applications_prometheus ON clusters_applications_prometheus.cluster_id = clusters.id + AND clusters_applications_prometheus.status IN (#{Applications::Prometheus.statuses[:installed]}, #{Applications::Prometheus.statuses[:updated]})") + } + + scope :without_active_prometheus_services, -> { + joins("LEFT JOIN services ON services.project_id = projects.id AND services.type = 'PrometheusService'") + .where("services.id IS NULL OR (services.active = FALSE AND services.properties = '{}')") + } + end + + class Cluster < ActiveRecord::Base + self.table_name = 'clusters' + + enum cluster_type: { + instance_type: 1, + group_type: 2 + } + + def self.has_prometheus_application? + joins("INNER JOIN clusters_applications_prometheus ON clusters_applications_prometheus.cluster_id = clusters.id + AND clusters_applications_prometheus.status IN (#{Applications::Prometheus.statuses[:installed]}, #{Applications::Prometheus.statuses[:updated]})").exists? + end + end + end + + def up + projects_without_active_prometheus_service.group('projects.id').each_batch(of: BATCH_SIZE) do |batch, index| + bg_migrations_batch = batch.select('projects.id').map { |project| [MIGRATION, project.id] } + delay = index * DELAY + BackgroundMigrationWorker.bulk_perform_in(delay.seconds, bg_migrations_batch) + end + end + + def down + # no-op + end + + private + + def projects_without_active_prometheus_service + scope = Migratable::Project.without_active_prometheus_services + + return scope if migrate_instance_cluster? + + scope.with_application_on_group_clusters + end + + def migrate_instance_cluster? + if instance_variable_defined?('@migrate_instance_cluster') + @migrate_instance_cluster + else + @migrate_instance_cluster = Migratable::Cluster.instance_type.has_prometheus_application? + end + end +end diff --git a/db/schema.rb b/db/schema.rb index acf51164e0b4b17d4b98423e0c260b192a110a16..8ee1f2ffea6bf717c89c50e8543694a15f559fbc 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: 2019_12_16_183532) do +ActiveRecord::Schema.define(version: 2020_01_17_112554) do # These are extensions that must be enabled in order to support this database enable_extension "pg_trgm" @@ -364,9 +364,13 @@ ActiveRecord::Schema.define(version: 2019_12_16_183532) do t.string "encrypted_slack_app_secret_iv", limit: 255 t.text "encrypted_slack_app_verification_token" t.string "encrypted_slack_app_verification_token_iv", limit: 255 + t.boolean "force_pages_access_control", default: false, null: false + t.boolean "updating_name_disabled_for_users", default: false, null: false + t.integer "instance_administrators_group_id" t.index ["custom_project_templates_group_id"], name: "index_application_settings_on_custom_project_templates_group_id" t.index ["file_template_project_id"], name: "index_application_settings_on_file_template_project_id" t.index ["instance_administration_project_id"], name: "index_applicationsettings_on_instance_administration_project_id" + t.index ["instance_administrators_group_id"], name: "index_application_settings_on_instance_administrators_group_id" t.index ["usage_stats_set_by_user_id"], name: "index_application_settings_on_usage_stats_set_by_user_id" end @@ -433,6 +437,13 @@ ActiveRecord::Schema.define(version: 2019_12_16_183532) do t.index ["group_id"], name: "index_approval_project_rules_groups_2" end + create_table "approval_project_rules_protected_branches", id: false, force: :cascade do |t| + t.bigint "approval_project_rule_id", null: false + t.bigint "protected_branch_id", null: false + t.index ["approval_project_rule_id", "protected_branch_id"], name: "index_approval_project_rules_protected_branches_unique", unique: true + t.index ["protected_branch_id"], name: "index_approval_project_rules_protected_branches_pb_id" + end + create_table "approval_project_rules_users", force: :cascade do |t| t.bigint "approval_project_rule_id", null: false t.integer "user_id", null: false @@ -684,6 +695,9 @@ ActiveRecord::Schema.define(version: 2019_12_16_183532) do t.datetime_with_timezone "scheduled_at" t.string "token_encrypted" t.integer "upstream_pipeline_id" + t.bigint "resource_group_id" + t.datetime_with_timezone "waiting_for_resource_at" + t.boolean "processed" t.index ["artifacts_expire_at"], name: "index_ci_builds_on_artifacts_expire_at", where: "(artifacts_file <> ''::text)" t.index ["auto_canceled_by_id"], name: "index_ci_builds_on_auto_canceled_by_id" t.index ["commit_id", "artifacts_expire_at", "id"], name: "index_ci_builds_on_commit_id_and_artifacts_expireatandidpartial", where: "(((type)::text = 'Ci::Build'::text) AND ((retried = false) OR (retried IS NULL)) AND ((name)::text = ANY (ARRAY[('sast'::character varying)::text, ('dependency_scanning'::character varying)::text, ('sast:container'::character varying)::text, ('container_scanning'::character varying)::text, ('dast'::character varying)::text])))" @@ -698,6 +712,7 @@ ActiveRecord::Schema.define(version: 2019_12_16_183532) do t.index ["project_id"], name: "index_ci_builds_on_project_id_for_successfull_pages_deploy", where: "(((type)::text = 'GenericCommitStatus'::text) AND ((stage)::text = 'deploy'::text) AND ((name)::text = 'pages:deploy'::text) AND ((status)::text = 'success'::text))" t.index ["protected"], name: "index_ci_builds_on_protected" t.index ["queued_at"], name: "index_ci_builds_on_queued_at" + t.index ["resource_group_id", "id"], name: "index_for_resource_group", where: "(resource_group_id IS NOT NULL)" t.index ["runner_id"], name: "index_ci_builds_on_runner_id" 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))" t.index ["stage_id", "stage_idx"], name: "tmp_build_stage_position_index", where: "(stage_idx IS NOT NULL)" @@ -860,6 +875,7 @@ ActiveRecord::Schema.define(version: 2019_12_16_183532) do t.index ["external_pull_request_id"], name: "index_ci_pipelines_on_external_pull_request_id", where: "(external_pull_request_id IS NOT NULL)" t.index ["merge_request_id"], name: "index_ci_pipelines_on_merge_request_id", where: "(merge_request_id IS NOT NULL)" t.index ["pipeline_schedule_id"], name: "index_ci_pipelines_on_pipeline_schedule_id" + t.index ["project_id", "id"], name: "index_ci_pipelines_on_project_id_and_id_desc", order: { id: :desc } t.index ["project_id", "iid"], name: "index_ci_pipelines_on_project_id_and_iid", unique: true, where: "(iid IS NOT NULL)" t.index ["project_id", "ref", "id"], name: "index_ci_pipelines_on_project_idandrefandiddesc", order: { id: :desc } t.index ["project_id", "ref", "status", "id"], name: "index_ci_pipelines_on_project_id_and_ref_and_status_and_id" @@ -867,11 +883,32 @@ ActiveRecord::Schema.define(version: 2019_12_16_183532) do t.index ["project_id", "source"], name: "index_ci_pipelines_on_project_id_and_source" t.index ["project_id", "status", "config_source"], name: "index_ci_pipelines_on_project_id_and_status_and_config_source" t.index ["project_id", "status", "updated_at"], name: "index_ci_pipelines_on_project_id_and_status_and_updated_at" - t.index ["project_id"], name: "index_ci_pipelines_on_project_id" t.index ["status"], name: "index_ci_pipelines_on_status" t.index ["user_id"], name: "index_ci_pipelines_on_user_id" end + create_table "ci_pipelines_config", primary_key: "pipeline_id", force: :cascade do |t| + t.text "content", null: false + t.index ["pipeline_id"], name: "index_ci_pipelines_config_on_pipeline_id" + end + + create_table "ci_resource_groups", force: :cascade do |t| + t.datetime_with_timezone "created_at", null: false + t.datetime_with_timezone "updated_at", null: false + t.bigint "project_id", null: false + t.string "key", limit: 255, null: false + t.index ["project_id", "key"], name: "index_ci_resource_groups_on_project_id_and_key", unique: true + end + + create_table "ci_resources", force: :cascade do |t| + t.datetime_with_timezone "created_at", null: false + t.datetime_with_timezone "updated_at", null: false + t.bigint "resource_group_id", null: false + t.bigint "build_id" + t.index ["build_id"], name: "index_ci_resources_on_build_id" + t.index ["resource_group_id", "build_id"], name: "index_ci_resources_on_resource_group_id_and_build_id", unique: true + end + create_table "ci_runner_namespaces", id: :serial, force: :cascade do |t| t.integer "runner_id" t.integer "namespace_id" @@ -966,7 +1003,7 @@ ActiveRecord::Schema.define(version: 2019_12_16_183532) do t.datetime "created_at" t.datetime "updated_at" t.integer "project_id" - t.integer "owner_id" + t.integer "owner_id", null: false t.string "description" t.string "ref" t.index ["owner_id"], name: "index_ci_triggers_on_owner_id" @@ -1142,6 +1179,7 @@ ActiveRecord::Schema.define(version: 2019_12_16_183532) do t.text "status_reason" t.string "external_ip" t.string "external_hostname" + t.boolean "modsecurity_enabled" t.index ["cluster_id"], name: "index_clusters_applications_ingress_on_cluster_id", unique: true end @@ -1434,6 +1472,7 @@ ActiveRecord::Schema.define(version: 2019_12_16_183532) do t.datetime_with_timezone "created_at", null: false t.datetime_with_timezone "updated_at", null: false t.integer "namespace_id" + t.index ["created_at"], name: "index_elasticsearch_indexed_namespaces_on_created_at" t.index ["namespace_id"], name: "index_elasticsearch_indexed_namespaces_on_namespace_id", unique: true end @@ -1500,7 +1539,6 @@ ActiveRecord::Schema.define(version: 2019_12_16_183532) do end create_table "epics", id: :serial, force: :cascade do |t| - t.integer "milestone_id" t.integer "group_id", null: false t.integer "author_id", null: false t.integer "assignee_id" @@ -1535,13 +1573,14 @@ ActiveRecord::Schema.define(version: 2019_12_16_183532) do t.index ["author_id"], name: "index_epics_on_author_id" t.index ["closed_by_id"], name: "index_epics_on_closed_by_id" t.index ["due_date_sourcing_epic_id"], name: "index_epics_on_due_date_sourcing_epic_id", where: "(due_date_sourcing_epic_id IS NOT NULL)" + t.index ["due_date_sourcing_milestone_id"], name: "index_epics_on_due_date_sourcing_milestone_id" t.index ["end_date"], name: "index_epics_on_end_date" t.index ["group_id"], name: "index_epics_on_group_id" t.index ["iid"], name: "index_epics_on_iid" - t.index ["milestone_id"], name: "index_milestone" t.index ["parent_id"], name: "index_epics_on_parent_id" t.index ["start_date"], name: "index_epics_on_start_date" t.index ["start_date_sourcing_epic_id"], name: "index_epics_on_start_date_sourcing_epic_id", where: "(start_date_sourcing_epic_id IS NOT NULL)" + t.index ["start_date_sourcing_milestone_id"], name: "index_epics_on_start_date_sourcing_milestone_id" end create_table "events", id: :serial, force: :cascade do |t| @@ -1998,14 +2037,17 @@ ActiveRecord::Schema.define(version: 2019_12_16_183532) do create_table "import_failures", force: :cascade do |t| t.integer "relation_index" - t.bigint "project_id", null: false + t.bigint "project_id" t.datetime_with_timezone "created_at", null: false t.string "relation_key", limit: 64 t.string "exception_class", limit: 128 t.string "correlation_id_value", limit: 128 t.string "exception_message", limit: 255 + t.integer "retry_count" + t.integer "group_id" t.index ["correlation_id_value"], name: "index_import_failures_on_correlation_id_value" - t.index ["project_id"], name: "index_import_failures_on_project_id" + t.index ["group_id"], name: "index_import_failures_on_group_id_not_null", where: "(group_id IS NOT NULL)" + t.index ["project_id"], name: "index_import_failures_on_project_id_not_null", where: "(project_id IS NOT NULL)" end create_table "index_statuses", id: :serial, force: :cascade do |t| @@ -2073,6 +2115,14 @@ ActiveRecord::Schema.define(version: 2019_12_16_183532) do t.index ["issue_id"], name: "index_issue_metrics" end + create_table "issue_milestones", id: false, force: :cascade do |t| + t.bigint "issue_id", null: false + t.bigint "milestone_id", null: false + t.index ["issue_id", "milestone_id"], name: "index_issue_milestones_on_issue_id_and_milestone_id", unique: true + t.index ["issue_id"], name: "index_issue_milestones_on_issue_id", unique: true + t.index ["milestone_id"], name: "index_issue_milestones_on_milestone_id" + end + create_table "issue_tracker_data", force: :cascade do |t| t.integer "service_id", null: false t.datetime_with_timezone "created_at", null: false @@ -2460,6 +2510,14 @@ ActiveRecord::Schema.define(version: 2019_12_16_183532) do t.index ["pipeline_id"], name: "index_merge_request_metrics_on_pipeline_id" end + create_table "merge_request_milestones", id: false, force: :cascade do |t| + t.bigint "merge_request_id", null: false + t.bigint "milestone_id", null: false + t.index ["merge_request_id", "milestone_id"], name: "index_mrs_milestones_on_mr_id_and_milestone_id", unique: true + t.index ["merge_request_id"], name: "index_merge_request_milestones_on_merge_request_id", unique: true + t.index ["milestone_id"], name: "index_merge_request_milestones_on_milestone_id" + end + create_table "merge_request_user_mentions", force: :cascade do |t| t.integer "merge_request_id", null: false t.integer "note_id" @@ -2896,12 +2954,6 @@ ActiveRecord::Schema.define(version: 2019_12_16_183532) do t.index ["package_id", "file_name"], name: "index_packages_package_files_on_package_id_and_file_name" end - create_table "packages_package_tags", force: :cascade do |t| - t.integer "package_id", null: false - t.string "name", limit: 255, null: false - t.index ["package_id"], name: "index_packages_package_tags_on_package_id" - end - create_table "packages_packages", force: :cascade do |t| t.integer "project_id", null: false t.datetime_with_timezone "created_at", null: false @@ -2914,6 +2966,15 @@ ActiveRecord::Schema.define(version: 2019_12_16_183532) do t.index ["project_id"], name: "index_packages_packages_on_project_id" end + create_table "packages_tags", force: :cascade do |t| + t.integer "package_id", null: false + t.string "name", limit: 255, null: false + t.datetime_with_timezone "created_at", null: false + t.datetime_with_timezone "updated_at", null: false + t.index ["package_id", "updated_at"], name: "index_packages_tags_on_package_id_and_updated_at", order: { updated_at: :desc } + t.index ["package_id"], name: "index_packages_tags_on_package_id" + end + create_table "pages_domain_acme_orders", force: :cascade do |t| t.integer "pages_domain_id", null: false t.datetime_with_timezone "expires_at", null: false @@ -3052,7 +3113,6 @@ ActiveRecord::Schema.define(version: 2019_12_16_183532) do t.integer "project_id", null: false t.boolean "group_runners_enabled", default: true, null: false t.boolean "merge_pipelines_enabled" - t.boolean "merge_trains_enabled", default: false, null: false t.integer "default_git_depth" t.index ["project_id"], name: "index_project_ci_cd_settings_on_project_id", unique: true end @@ -3110,6 +3170,7 @@ ActiveRecord::Schema.define(version: 2019_12_16_183532) do t.datetime "updated_at" t.integer "repository_access_level", default: 20, null: false t.integer "pages_access_level", null: false + t.integer "forking_access_level" t.index ["project_id"], name: "index_project_features_on_project_id", unique: true end @@ -3154,7 +3215,6 @@ ActiveRecord::Schema.define(version: 2019_12_16_183532) do t.text "last_error" t.datetime_with_timezone "last_update_at" t.datetime_with_timezone "last_successful_update_at" - t.index ["jid"], name: "index_project_mirror_data_on_jid" t.index ["last_successful_update_at"], name: "index_project_mirror_data_on_last_successful_update_at" t.index ["last_update_at", "retry_count"], name: "index_project_mirror_data_on_last_update_at_and_retry_count" t.index ["next_execution_timestamp", "retry_count"], name: "index_mirror_data_on_next_execution_and_retry_count" @@ -3302,14 +3362,23 @@ ActiveRecord::Schema.define(version: 2019_12_16_183532) do t.boolean "remove_source_branch_after_merge" t.date "marked_for_deletion_at" t.integer "marked_for_deletion_by_user_id" + t.boolean "autoclose_referenced_issues" + t.string "suggestion_commit_message", limit: 255 t.index "lower((name)::text)", name: "index_projects_on_lower_name" + t.index ["created_at", "id"], name: "index_projects_api_created_at_id_desc", order: { id: :desc } + t.index ["created_at", "id"], name: "index_projects_api_vis20_created_at", where: "(visibility_level = 20)" + t.index ["created_at", "id"], name: "index_projects_api_vis20_created_at_id_desc", order: { id: :desc }, where: "(visibility_level = 20)" t.index ["created_at", "id"], name: "index_projects_on_created_at_and_id" t.index ["creator_id"], name: "index_projects_on_creator_id" t.index ["description"], name: "index_projects_on_description_trigram", opclass: :gin_trgm_ops, using: :gin t.index ["id", "repository_storage", "last_repository_updated_at"], name: "idx_projects_on_repository_storage_last_repository_updated_at" + t.index ["id"], name: "index_on_id_partial_with_legacy_storage", where: "((storage_version < 2) OR (storage_version IS NULL))" t.index ["id"], name: "index_projects_on_id_partial_for_visibility", unique: true, where: "(visibility_level = ANY (ARRAY[10, 20]))" t.index ["id"], name: "index_projects_on_mirror_and_mirror_trigger_builds_both_true", where: "((mirror IS TRUE) AND (mirror_trigger_builds IS TRUE))" - t.index ["last_activity_at"], name: "index_projects_on_last_activity_at" + t.index ["last_activity_at", "id"], name: "index_projects_api_last_activity_at_id_desc", order: { id: :desc } + t.index ["last_activity_at", "id"], name: "index_projects_api_vis20_last_activity_at", where: "(visibility_level = 20)" + t.index ["last_activity_at", "id"], name: "index_projects_api_vis20_last_activity_at_id_desc", order: { id: :desc }, where: "(visibility_level = 20)" + t.index ["last_activity_at", "id"], name: "index_projects_on_last_activity_at_and_id" t.index ["last_repository_check_at"], name: "index_projects_on_last_repository_check_at", where: "(last_repository_check_at IS NOT NULL)" t.index ["last_repository_check_failed"], name: "index_projects_on_last_repository_check_failed" t.index ["last_repository_updated_at"], name: "index_projects_on_last_repository_updated_at" @@ -3317,9 +3386,16 @@ ActiveRecord::Schema.define(version: 2019_12_16_183532) do t.index ["marked_for_deletion_by_user_id"], name: "index_projects_on_marked_for_deletion_by_user_id", where: "(marked_for_deletion_by_user_id IS NOT NULL)" t.index ["mirror_last_successful_update_at"], name: "index_projects_on_mirror_last_successful_update_at" t.index ["mirror_user_id"], name: "index_projects_on_mirror_user_id" + t.index ["name", "id"], name: "index_projects_api_name_id_desc", order: { id: :desc } + t.index ["name", "id"], name: "index_projects_api_vis20_name", where: "(visibility_level = 20)" + t.index ["name", "id"], name: "index_projects_api_vis20_name_id_desc", order: { id: :desc }, where: "(visibility_level = 20)" + t.index ["name", "id"], name: "index_projects_on_name_and_id" t.index ["name"], name: "index_projects_on_name_trigram", opclass: :gin_trgm_ops, using: :gin t.index ["namespace_id"], name: "index_projects_on_namespace_id" - t.index ["path"], name: "index_projects_on_path" + t.index ["path", "id"], name: "index_projects_api_path_id_desc", order: { id: :desc } + t.index ["path", "id"], name: "index_projects_api_vis20_path", where: "(visibility_level = 20)" + t.index ["path", "id"], name: "index_projects_api_vis20_path_id_desc", order: { id: :desc }, where: "(visibility_level = 20)" + t.index ["path", "id"], name: "index_projects_on_path_and_id" t.index ["path"], name: "index_projects_on_path_trigram", opclass: :gin_trgm_ops, using: :gin t.index ["pending_delete"], name: "index_projects_on_pending_delete" t.index ["pool_repository_id"], name: "index_projects_on_pool_repository_id", where: "(pool_repository_id IS NOT NULL)" @@ -3328,7 +3404,10 @@ ActiveRecord::Schema.define(version: 2019_12_16_183532) do t.index ["runners_token"], name: "index_projects_on_runners_token" t.index ["runners_token_encrypted"], name: "index_projects_on_runners_token_encrypted" t.index ["star_count"], name: "index_projects_on_star_count" - t.index ["visibility_level", "created_at", "id"], name: "index_projects_on_visibility_level_and_created_at_and_id" + t.index ["updated_at", "id"], name: "index_projects_api_updated_at_id_desc", order: { id: :desc } + t.index ["updated_at", "id"], name: "index_projects_api_vis20_updated_at", where: "(visibility_level = 20)" + t.index ["updated_at", "id"], name: "index_projects_api_vis20_updated_at_id_desc", order: { id: :desc }, where: "(visibility_level = 20)" + t.index ["updated_at", "id"], name: "index_projects_on_updated_at_and_id" end create_table "prometheus_alert_events", force: :cascade do |t| @@ -3578,6 +3657,15 @@ ActiveRecord::Schema.define(version: 2019_12_16_183532) do t.index ["user_id"], name: "index_resource_label_events_on_user_id" end + create_table "resource_weight_events", force: :cascade do |t| + t.bigint "user_id", null: false + t.bigint "issue_id", null: false + t.integer "weight" + t.datetime_with_timezone "created_at", null: false + t.index ["issue_id", "weight"], name: "index_resource_weight_events_on_issue_id_and_weight" + t.index ["user_id"], name: "index_resource_weight_events_on_user_id" + end + create_table "reviews", force: :cascade do |t| t.integer "author_id" t.integer "merge_request_id", null: false @@ -3649,6 +3737,7 @@ ActiveRecord::Schema.define(version: 2019_12_16_183532) do t.bigint "issue_id", null: false t.bigint "sentry_issue_identifier", null: false t.index ["issue_id"], name: "index_sentry_issues_on_issue_id", unique: true + t.index ["sentry_issue_identifier"], name: "index_sentry_issues_on_sentry_issue_identifier" end create_table "serverless_domain_cluster", primary_key: "uuid", id: :string, limit: 14, force: :cascade do |t| @@ -3693,6 +3782,7 @@ ActiveRecord::Schema.define(version: 2019_12_16_183532) do t.string "description", limit: 500 t.boolean "comment_on_event_enabled", default: true, null: false t.index ["project_id"], name: "index_services_on_project_id" + t.index ["project_id"], name: "tmp_index_on_project_id_partial_with_prometheus_services", where: "((type)::text = 'PrometheusService'::text)" t.index ["template"], name: "index_services_on_template" t.index ["type"], name: "index_services_on_type" end @@ -3751,6 +3841,8 @@ ActiveRecord::Schema.define(version: 2019_12_16_183532) do t.string "encrypted_secret_token", limit: 255 t.string "encrypted_secret_token_iv", limit: 255 t.boolean "secret", default: false, null: false + t.string "repository_storage", limit: 255, default: "default", null: false + t.integer "storage_version", default: 2, null: false t.index ["author_id"], name: "index_snippets_on_author_id" t.index ["content"], name: "index_snippets_on_content_trigram", opclass: :gin_trgm_ops, using: :gin t.index ["created_at"], name: "index_snippets_on_created_at" @@ -3765,6 +3857,8 @@ ActiveRecord::Schema.define(version: 2019_12_16_183532) do t.integer "project_id", null: false t.integer "software_license_id", null: false t.integer "classification", default: 0, null: false + t.datetime_with_timezone "created_at" + t.datetime_with_timezone "updated_at" t.index ["project_id", "software_license_id"], name: "index_software_license_policies_unique_per_project", unique: true t.index ["software_license_id"], name: "index_software_license_policies_on_software_license_id" end @@ -3986,6 +4080,7 @@ ActiveRecord::Schema.define(version: 2019_12_16_183532) do t.boolean "show_whitespace_in_diffs", default: true, null: false t.boolean "sourcegraph_enabled" t.boolean "setup_for_company" + t.boolean "render_whitespace_in_code" t.index ["user_id"], name: "index_user_preferences_on_user_id", unique: true end @@ -4351,6 +4446,7 @@ ActiveRecord::Schema.define(version: 2019_12_16_183532) do add_foreign_key "analytics_repository_file_edits", "projects", on_delete: :cascade add_foreign_key "analytics_repository_files", "projects", on_delete: :cascade add_foreign_key "application_settings", "namespaces", column: "custom_project_templates_group_id", on_delete: :nullify + add_foreign_key "application_settings", "namespaces", column: "instance_administrators_group_id", name: "fk_e8a145f3a7", on_delete: :nullify add_foreign_key "application_settings", "projects", column: "file_template_project_id", name: "fk_ec757bd087", on_delete: :nullify add_foreign_key "application_settings", "projects", column: "instance_administration_project_id", on_delete: :nullify add_foreign_key "application_settings", "users", column: "usage_stats_set_by_user_id", name: "fk_964370041d", on_delete: :nullify @@ -4366,6 +4462,8 @@ ActiveRecord::Schema.define(version: 2019_12_16_183532) do add_foreign_key "approval_project_rules", "projects", on_delete: :cascade add_foreign_key "approval_project_rules_groups", "approval_project_rules", on_delete: :cascade add_foreign_key "approval_project_rules_groups", "namespaces", column: "group_id", on_delete: :cascade + add_foreign_key "approval_project_rules_protected_branches", "approval_project_rules", on_delete: :cascade + add_foreign_key "approval_project_rules_protected_branches", "protected_branches", on_delete: :cascade add_foreign_key "approval_project_rules_users", "approval_project_rules", on_delete: :cascade add_foreign_key "approval_project_rules_users", "users", on_delete: :cascade add_foreign_key "approvals", "merge_requests", name: "fk_310d714958", on_delete: :cascade @@ -4395,6 +4493,7 @@ ActiveRecord::Schema.define(version: 2019_12_16_183532) do add_foreign_key "ci_builds", "ci_pipelines", column: "auto_canceled_by_id", name: "fk_a2141b1522", on_delete: :nullify add_foreign_key "ci_builds", "ci_pipelines", column: "commit_id", name: "fk_d3130c9a7f", on_delete: :cascade add_foreign_key "ci_builds", "ci_pipelines", column: "upstream_pipeline_id", name: "fk_87f4cefcda", on_delete: :cascade + add_foreign_key "ci_builds", "ci_resource_groups", column: "resource_group_id", name: "fk_6661f4f0e8", on_delete: :nullify add_foreign_key "ci_builds", "ci_stages", column: "stage_id", name: "fk_3a9eaa254d", on_delete: :cascade add_foreign_key "ci_builds", "projects", name: "fk_befce0568a", on_delete: :cascade add_foreign_key "ci_builds_metadata", "ci_builds", column: "build_id", on_delete: :cascade @@ -4415,6 +4514,10 @@ ActiveRecord::Schema.define(version: 2019_12_16_183532) do add_foreign_key "ci_pipelines", "external_pull_requests", name: "fk_190998ef09", 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_pipelines_config", "ci_pipelines", column: "pipeline_id", on_delete: :cascade + add_foreign_key "ci_resource_groups", "projects", name: "fk_774722d144", on_delete: :cascade + add_foreign_key "ci_resources", "ci_builds", column: "build_id", name: "fk_e169a8e3d5", on_delete: :nullify + add_foreign_key "ci_resources", "ci_resource_groups", column: "resource_group_id", 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 add_foreign_key "ci_runner_projects", "projects", name: "fk_4478a6f1e4", on_delete: :cascade @@ -4490,7 +4593,8 @@ ActiveRecord::Schema.define(version: 2019_12_16_183532) do add_foreign_key "epics", "epics", column: "due_date_sourcing_epic_id", name: "fk_013c9f36ca", on_delete: :nullify add_foreign_key "epics", "epics", column: "parent_id", name: "fk_25b99c1be3", on_delete: :cascade add_foreign_key "epics", "epics", column: "start_date_sourcing_epic_id", name: "fk_9d480c64b2", on_delete: :nullify - add_foreign_key "epics", "milestones", on_delete: :nullify + add_foreign_key "epics", "milestones", column: "due_date_sourcing_milestone_id", name: "fk_3c1fd1cccc", on_delete: :nullify + add_foreign_key "epics", "milestones", column: "start_date_sourcing_milestone_id", name: "fk_1fbed67632", on_delete: :nullify add_foreign_key "epics", "namespaces", column: "group_id", name: "fk_f081aa4489", on_delete: :cascade add_foreign_key "epics", "users", column: "assignee_id", name: "fk_dccd3f98fc", on_delete: :nullify add_foreign_key "epics", "users", column: "author_id", name: "fk_3654b61b03", on_delete: :cascade @@ -4544,6 +4648,7 @@ ActiveRecord::Schema.define(version: 2019_12_16_183532) do add_foreign_key "identities", "saml_providers", name: "fk_aade90f0fc", on_delete: :cascade add_foreign_key "import_export_uploads", "namespaces", column: "group_id", name: "fk_83319d9721", on_delete: :cascade add_foreign_key "import_export_uploads", "projects", on_delete: :cascade + add_foreign_key "import_failures", "namespaces", column: "group_id", name: "fk_24b824da43", on_delete: :cascade add_foreign_key "index_statuses", "projects", name: "fk_74b2492545", on_delete: :cascade add_foreign_key "insights", "namespaces", on_delete: :cascade add_foreign_key "insights", "projects", on_delete: :cascade @@ -4555,6 +4660,8 @@ ActiveRecord::Schema.define(version: 2019_12_16_183532) do add_foreign_key "issue_links", "issues", column: "source_id", name: "fk_c900194ff2", on_delete: :cascade add_foreign_key "issue_links", "issues", column: "target_id", name: "fk_e71bb44f1f", on_delete: :cascade add_foreign_key "issue_metrics", "issues", on_delete: :cascade + add_foreign_key "issue_milestones", "issues", on_delete: :cascade + add_foreign_key "issue_milestones", "milestones", on_delete: :cascade add_foreign_key "issue_tracker_data", "services", on_delete: :cascade add_foreign_key "issue_user_mentions", "issues", on_delete: :cascade add_foreign_key "issue_user_mentions", "notes", on_delete: :cascade @@ -4598,6 +4705,8 @@ ActiveRecord::Schema.define(version: 2019_12_16_183532) do add_foreign_key "merge_request_metrics", "merge_requests", on_delete: :cascade add_foreign_key "merge_request_metrics", "users", column: "latest_closed_by_id", name: "fk_ae440388cc", on_delete: :nullify add_foreign_key "merge_request_metrics", "users", column: "merged_by_id", name: "fk_7f28d925f3", on_delete: :nullify + add_foreign_key "merge_request_milestones", "merge_requests", on_delete: :cascade + add_foreign_key "merge_request_milestones", "milestones", on_delete: :cascade add_foreign_key "merge_request_user_mentions", "merge_requests", on_delete: :cascade add_foreign_key "merge_request_user_mentions", "notes", on_delete: :cascade add_foreign_key "merge_requests", "ci_pipelines", column: "head_pipeline_id", name: "fk_fd82eae0b9", on_delete: :nullify @@ -4641,13 +4750,13 @@ ActiveRecord::Schema.define(version: 2019_12_16_183532) do add_foreign_key "packages_dependency_links", "packages_packages", column: "package_id", on_delete: :cascade add_foreign_key "packages_maven_metadata", "packages_packages", column: "package_id", name: "fk_be88aed360", on_delete: :cascade add_foreign_key "packages_package_files", "packages_packages", column: "package_id", name: "fk_86f0f182f8", on_delete: :cascade - add_foreign_key "packages_package_tags", "packages_packages", column: "package_id", on_delete: :cascade add_foreign_key "packages_packages", "projects", on_delete: :cascade + add_foreign_key "packages_tags", "packages_packages", column: "package_id", on_delete: :cascade add_foreign_key "pages_domain_acme_orders", "pages_domains", on_delete: :cascade add_foreign_key "pages_domains", "projects", name: "fk_ea2f6dfc6f", on_delete: :cascade add_foreign_key "path_locks", "projects", name: "fk_5265c98f24", on_delete: :cascade add_foreign_key "path_locks", "users" - add_foreign_key "personal_access_tokens", "users" + add_foreign_key "personal_access_tokens", "users", name: "fk_personal_access_tokens_user_id", on_delete: :cascade add_foreign_key "plan_limits", "plans", on_delete: :cascade add_foreign_key "pool_repositories", "projects", column: "source_project_id", on_delete: :nullify add_foreign_key "pool_repositories", "shards", on_delete: :restrict @@ -4713,6 +4822,8 @@ ActiveRecord::Schema.define(version: 2019_12_16_183532) do add_foreign_key "resource_label_events", "labels", on_delete: :nullify add_foreign_key "resource_label_events", "merge_requests", on_delete: :cascade add_foreign_key "resource_label_events", "users", on_delete: :nullify + add_foreign_key "resource_weight_events", "issues", on_delete: :cascade + add_foreign_key "resource_weight_events", "users", on_delete: :nullify add_foreign_key "reviews", "merge_requests", on_delete: :cascade add_foreign_key "reviews", "projects", on_delete: :cascade add_foreign_key "reviews", "users", column: "author_id", on_delete: :nullify diff --git a/doc/README.md b/doc/README.md index 1cdb5bc7b4769d6ccff4e65885578babb0c42af9..c3db960514f76da8f44a16a93ef8544a40b9e18b 100644 --- a/doc/README.md +++ b/doc/README.md @@ -364,6 +364,7 @@ The following documentation relates to the DevOps **Secure** stage: | [Dependency Scanning](user/application_security/dependency_scanning/index.md) **(ULTIMATE)** | Analyze your dependencies for known vulnerabilities. | | [Dynamic Application Security Testing (DAST)](user/application_security/dast/index.md) **(ULTIMATE)** | Analyze running web applications for known vulnerabilities. | | [Group Security Dashboard](user/application_security/security_dashboard/index.md#group-security-dashboard) **(ULTIMATE)** | View vulnerabilities in all the projects in a group and its subgroups. | +| [Instance Security Dashboard](user/application_security/security_dashboard/index.md#instance-security-dashboard) **(ULTIMATE)** | View vulnerabilities in all the projects you're interested in. | | [License Compliance](user/application_security/license_compliance/index.md) **(ULTIMATE)** | Search your project's dependencies for their licenses. | | [Pipeline Security Dashboard](user/application_security/security_dashboard/index.md#pipeline-security-dashboard) **(ULTIMATE)** | View the security reports for your project's pipelines. | | [Project Security Dashboard](user/application_security/security_dashboard/index.md#project-security-dashboard) **(ULTIMATE)** | View the latest security reports for your project. | diff --git a/doc/administration/audit_events.md b/doc/administration/audit_events.md index ccb4ccbd5256778a709391257aada06d95ef3c35..7d3be9e1bd31172aefb2b5dba3d5ad70da9fd014 100644 --- a/doc/administration/audit_events.md +++ b/doc/administration/audit_events.md @@ -18,7 +18,7 @@ permission level, who added a new user, or who removed a user. ## Use-cases -- Check who was the person who changed the permission level of a particular +- Check who the person was that changed the permission level of a particular user for a project in GitLab. - Use it to track which users have access to a certain group of projects in GitLab, and who gave them that permission level. @@ -80,6 +80,9 @@ From there, you can see the following actions: - Project was archived - Project was unarchived - Added/removed/updated protected branches +- Release was added to a project +- Release was updated +- Release milestone associations changed ### Instance events **(PREMIUM ONLY)** @@ -104,7 +107,7 @@ recorded: - Started/stopped user impersonation It is possible to filter particular actions by choosing an audit data type from -the filter drop-down. You can further filter by specific group, project or user +the filter dropdown box. You can further filter by specific group, project or user (for authentication events).  diff --git a/doc/administration/auditor_users.md b/doc/administration/auditor_users.md index 9b4d0f443cfac7063c8302cdf75369aefcb78dd4..46f8ae25916874933018f04a1eff0e6d832c342a 100644 --- a/doc/administration/auditor_users.md +++ b/doc/administration/auditor_users.md @@ -9,7 +9,7 @@ resources on the GitLab instance. Auditor users can have full access to their own resources (projects, groups, snippets, etc.), and read-only access to **all** other resources, except the -Admin area. To put another way, they are just regular users (who can be added +Admin Area. To put another way, they are just regular users (who can be added to projects, create personal snippets, create milestones on their groups, etc.) who also happen to have read-only access to all projects on the system that they haven't been explicitly [given access][permissions] to. @@ -28,7 +28,7 @@ To sum up, assuming you have logged-in as an Auditor user: have the same access as the [permissions] they were given to. For example, if they were added as a Developer, they could then push commits or comment on issues. -- The Auditor cannot view the Admin area, or perform any admin actions. +- The Auditor cannot view the Admin Area, or perform any admin actions. For more information about what an Auditor can or can't do, see the [Permissions and restrictions of an Auditor user](#permissions-and-restrictions-of-an-auditor-user) @@ -73,7 +73,7 @@ instance, with the following permissions/restrictions: - Can read issues / MRs - Can read project snippets - Cannot be Admin and Auditor at the same time -- Cannot access the Admin area +- Cannot access the Admin Area - In a group / project they're not a member of: - Cannot access project settings - Cannot access group settings diff --git a/doc/administration/auth/how_to_configure_ldap_gitlab_ce/index.md b/doc/administration/auth/how_to_configure_ldap_gitlab_ce/index.md index 743893d984ab3ef9b73b11ba7245a8e8085d4635..800bb28c664692635812c151d0a58dfe3761ad36 100644 --- a/doc/administration/auth/how_to_configure_ldap_gitlab_ce/index.md +++ b/doc/administration/auth/how_to_configure_ldap_gitlab_ce/index.md @@ -23,7 +23,7 @@ For example, [Active Directory](https://docs.microsoft.com/en-us/previous-versio - [Oracle Internet Directory](https://www.oracle.com/middleware/technologies/internet-directory.html) - [OpenLDAP](http://www.openldap.org/) - [389 Directory](http://directory.fedoraproject.org/) -- [OpenDJ (Renamed to Foregerock Directory Services)](https://www.forgerock.com/platform/directory-services) +- [OpenDJ (Renamed to Forgerock Directory Services)](https://www.forgerock.com/platform/directory-services) - [ApacheDS](https://directory.apache.org/) > GitLab uses the [Net::LDAP](https://rubygems.org/gems/net-ldap) library under the hood. This means it supports all [IETF](https://tools.ietf.org/html/rfc2251) compliant LDAPv3 servers. diff --git a/doc/administration/auth/ldap-ee.md b/doc/administration/auth/ldap-ee.md index 34fd97a24eec5c0f4cd3e4c78ac36530bfbfd566..a15e34c33a537a1fef024c62e323d2d80fe346fb 100644 --- a/doc/administration/auth/ldap-ee.md +++ b/doc/administration/auth/ldap-ee.md @@ -452,7 +452,7 @@ things to check to debug the situation. links by visiting the GitLab group, then **Settings dropdown > LDAP groups**. - Check that the user has an LDAP identity: 1. Sign in to GitLab as an administrator user. - 1. Navigate to **Admin area > Users**. + 1. Navigate to **Admin Area > Users**. 1. Search for the user 1. Open the user, by clicking on their name. Do not click 'Edit'. 1. Navigate to the **Identities** tab. There should be an LDAP identity with diff --git a/doc/administration/auth/ldap.md b/doc/administration/auth/ldap.md index d449a5a72afb86ff6d9fea06daac8ea831109366..857f554f2fec954133a69f412e9cb6493340dfc7 100644 --- a/doc/administration/auth/ldap.md +++ b/doc/administration/auth/ldap.md @@ -52,7 +52,7 @@ If a user is deleted from the LDAP server, they will be blocked in GitLab as well. Users will be immediately blocked from logging in. However, there is an LDAP check cache time of one hour (see note) which means users that are already logged in or are using Git over SSH will still be able to access -GitLab for up to one hour. Manually block the user in the GitLab Admin area to +GitLab for up to one hour. Manually block the user in the GitLab Admin Area to immediately block all access. NOTE: **Note**: diff --git a/doc/administration/file_hooks.md b/doc/administration/file_hooks.md new file mode 100644 index 0000000000000000000000000000000000000000..5ef739a42899902e36c51644265d4e093e7a526f --- /dev/null +++ b/doc/administration/file_hooks.md @@ -0,0 +1,116 @@ +# File hooks + +> Introduced in GitLab 10.6. +> Until 12.8 the feature name was Plugins. + +With custom file hooks, GitLab administrators can introduce custom integrations +without modifying GitLab's source code. + +NOTE: **Note:** +Instead of writing and supporting your own file hook you can make changes +directly to the GitLab source code and contribute back upstream. This way we can +ensure functionality is preserved across versions and covered by tests. + +NOTE: **Note:** +File hooks must be configured on the filesystem of the GitLab server. Only GitLab +server administrators will be able to complete these tasks. Explore +[system hooks] or [webhooks] as an option if you do not have filesystem access. + +A file hook will run on each event so it's up to you to filter events or projects +within a file hook code. You can have as many file hooks as you want. Each file hook will +be triggered by GitLab asynchronously in case of an event. For a list of events +see the [system hooks] documentation. + +## Setup + +The file hooks must be placed directly into the `plugins` directory, subdirectories +will be ignored. There is an +[`example` directory inside `plugins`](https://gitlab.com/gitlab-org/gitlab/tree/master/plugins/examples) +where you can find some basic examples. + +Follow the steps below to set up a custom hook: + +1. On the GitLab server, navigate to the plugin directory. + For an installation from source the path is usually + `/home/git/gitlab/plugins/`. For Omnibus installs the path is + usually `/opt/gitlab/embedded/service/gitlab-rails/plugins`. + + For [highly available] configurations, your hook file should exist on each + application server. + +1. Inside the `plugins` directory, create a file with a name of your choice, + without spaces or special characters. +1. Make the hook file executable and make sure it's owned by the Git user. +1. Write the code to make the file hook function as expected. That can be + in any language, and ensure the 'shebang' at the top properly reflects the + language type. For example, if the script is in Ruby the shebang will + probably be `#!/usr/bin/env ruby`. +1. The data to the file hook will be provided as JSON on STDIN. It will be exactly + same as for [system hooks] + +That's it! Assuming the file hook code is properly implemented, the hook will fire +as appropriate. The file hooks file list is updated for each event, there is no +need to restart GitLab to apply a new file hook. + +If a file hook executes with non-zero exit code or GitLab fails to execute it, a +message will be logged to: + +- `gitlab-rails/plugin.log` in an Omnibus installation. +- `log/plugin.log` in a source installation. + +## Creating file hooks + +Below is an example that will only response on the event `project_create` and +will inform the admins from the GitLab instance that a new project has been created. + +```ruby +# By using the embedded ruby version we eliminate the possibility that our chosen language +# would be unavailable from +#!/opt/gitlab/embedded/bin/ruby +require 'json' +require 'mail' + +# The incoming variables are in JSON format so we need to parse it first. +ARGS = JSON.parse(STDIN.read) + +# We only want to trigger this file hook on the event project_create +return unless ARGS['event_name'] == 'project_create' + +# We will inform our admins of our gitlab instance that a new project is created +Mail.deliver do + from 'info@gitlab_instance.com' + to 'admin@gitlab_instance.com' + subject "new project " + ARGS['name'] + body ARGS['owner_name'] + 'created project ' + ARGS['name'] +end +``` + +## Validation + +Writing your own file hook can be tricky and it's easier if you can check it +without altering the system. A rake task is provided so that you can use it +in a staging environment to test your file hook before using it in production. +The rake task will use a sample data and execute each of file hook. The output +should be enough to determine if the system sees your file hook and if it was +executed without errors. + +```bash +# Omnibus installations +sudo gitlab-rake file_hooks:validate + +# Installations from source +cd /home/git/gitlab +bundle exec rake file_hooks:validate RAILS_ENV=production +``` + +Example of output: + +``` +Validating file hooks from /plugins directory +* /home/git/gitlab/plugins/save_to_file.clj succeed (zero exit code) +* /home/git/gitlab/plugins/save_to_file.rb failure (non-zero exit code) +``` + +[system hooks]: ../system_hooks/system_hooks.md +[webhooks]: ../user/project/integrations/webhooks.md +[highly available]: ./high_availability/README.md diff --git a/doc/administration/geo/replication/database.md b/doc/administration/geo/replication/database.md index 72c3692716b26edbf5f71202190ec69184292092..bddd30dbb2a23dadfe86dce7e24286371cfabb8a 100644 --- a/doc/administration/geo/replication/database.md +++ b/doc/administration/geo/replication/database.md @@ -266,13 +266,13 @@ There is an [issue where support is being discussed](https://gitlab.com/gitlab-o 1. SSH into your GitLab **secondary** server and login as root: - ``` + ```sh sudo -i ``` 1. Stop application server and Sidekiq - ``` + ```sh gitlab-ctl stop unicorn gitlab-ctl stop sidekiq ``` @@ -295,7 +295,7 @@ There is an [issue where support is being discussed](https://gitlab.com/gitlab-o 1. Create a file `server.crt` in the **secondary** server, with the content you got on the last step of the **primary** node's setup: - ``` + ```sh editor server.crt ``` diff --git a/doc/administration/geo/replication/datatypes.md b/doc/administration/geo/replication/datatypes.md new file mode 100644 index 0000000000000000000000000000000000000000..75ce7503c34094f31ffbcaa80a755693a802d84e --- /dev/null +++ b/doc/administration/geo/replication/datatypes.md @@ -0,0 +1,168 @@ +# Geo data types support + +A Geo data type is a specific class of data that is required by one or more GitLab features to +store relevant information. + +To replicate data produced by these features with Geo, we use several strategies to access, transfer, and verify them. + +## Data types + +We currently distinguish between three different data types: + +- [Git repositories](#git-repositories) +- [Blobs](#blobs) +- [Database](#database) + +See the list below of each feature or component we replicate, its corresponding data type, replication and +verification methods: + +| Type | Feature / component | Replication method | Verification method | +|----------|-----------------------------------------------|---------------------------------------------|----------------------| +| Database | Application data in PostgreSQL | Native | Native | +| Database | Redis | _N/A_ (*1*) | _N/A_ | +| Database | Elasticsearch | Native | Native | +| Database | Personal snippets | Postgres Replication | Postgres Replication | +| Database | Project snippets | Postgres Replication | Postgres Replication | +| Database | SSH public keys | Postgres Replication | Postgres Replication | +| Git | Project repository | Geo with Gitaly | Gitaly Checksum | +| Git | Project wiki repository | Geo with Gitaly | Gitaly Checksum | +| Git | Project designs repository | Geo with Gitaly | Gitaly Checksum | +| Git | Object pools for forked project deduplication | Geo with Gitaly | _Not implemented_ | +| Blobs | User uploads _(filesystem)_ | Geo with API | _Not implemented_ | +| Blobs | User uploads _(object storage)_ | Geo with API/Managed (*2*) | _Not implemented_ | +| Blobs | LFS objects _(filesystem)_ | Geo with API | _Not implemented_ | +| Blobs | LFS objects _(object storage)_ | Geo with API/Managed (*2*) | _Not implemented_ | +| Blobs | CI job artifacts _(filesystem)_ | Geo with API | _Not implemented_ | +| Blobs | CI job artifacts _(object storage)_ | Geo with API/Managed (*2*) | _Not implemented_ | +| Blobs | Archived CI build traces _(filesystem)_ | Geo with API | _Not implemented_ | +| Blobs | Archived CI build traces _(object storage)_ | Geo with API/Managed (*2*) | _Not implemented_ | +| Blobs | Container registry _(filesystem)_ | Geo with API/Docker API | _Not implemented_ | +| Blobs | Container registry _(object storage)_ | Geo with API/Managed/Docker API (*2*) | _Not implemented_ | + +- (*1*): Redis replication can be used as part of HA with Redis sentinel. It's not used between Geo nodes. +- (*2*): Object storage replication can be performed by Geo or by your object storage provider/appliance + native replication feature. + +### Git repositories + +A GitLab instance can have one or more repository shards. Each shard has a Gitaly instance that +is responsible for allowing access and operations on the locally stored Git repositories. It can run +on a machine with a single disk, multiple disks mounted as a single mount-point (like with a RAID array), +or using LVM. + +It requires no special filesystem and can work with NFS or a mounted Storage Appliance (there may be +performance limitations when using a remote filesystem). + +Communication is done via Gitaly's own gRPC API. There are three possible ways of synchronization: + +- Using regular Git clone/fetch from one Geo node to another (with special authentication). +- Using repository snapshots (for when the first method fails or repository is corrupt). +- Manual trigger from the Admin UI (a combination of both of the above). + +Each project can have at most 3 different repositories: + +- A project repository, where the source code is stored. +- A wiki repository, where the wiki content is stored. +- A design repository, where design artifacts are indexed (assets are actually in LFS). + +They all live in the same shard and share the same base name with a `-wiki` and `-design` suffix +for Wiki and Design Repository cases. + +### Blobs + +GitLab stores files and blobs such as Issue attachments or LFS objects into either: + +- The filesystem in a specific location. +- An Object Storage solution. Object Storage solutions can be: + - Cloud based like Amazon S3 Google Cloud Storage. + - Self hosted (like MinIO). + - A Storage Appliance that exposes an Object Storage-compatible API. + +When using the filesystem store instead of Object Storage, you need to use network mounted filesystems +to run GitLab when using more than one server (for example with a High Availability setup). + +With respect to replication and verification: + +- We transfer files and blobs using an internal API request. +- With Object Storage, you can either: + - Use a cloud provider replication functionality. + - Have GitLab replicate it for you. + +### Database + +GitLab relies on data stored in multiple databases, for different use-cases. +PostgreSQL is the single point of truth for user-generated content in the Web interface, like issues content, comments +as well as permissions and credentials. + +PostgreSQL can also hold some level of cached data like HTML rendered Markdown, cached merge-requests diff (this can +also be configured to be offloaded to object storage). + +We use PostgreSQL's own replication functionality to replicate data from the primary to secondary nodes. + +We use Redis both as a cache store and to hold persistent data for our background jobs system. Because both +use-cases has data that are exclusive to the same Geo node, we don't replicate it between nodes. + +Elasticsearch is an optional database, that can enable advanced searching capabilities, like improved Global Search +in both source-code level and user generated content in Issues / Merge-Requests and discussions. Currently it's not +supported in Geo. + +## Limitations on replication/verification + +The following table lists the GitLab features along with their replication +and verification status on a **secondary** node. + +You can keep track of the progress to implement the missing items in +these epics/issues: + +- [Unreplicated Data Types](https://gitlab.com/groups/gitlab-org/-/epics/893) +- [Verify all replicated data](https://gitlab.com/groups/gitlab-org/-/epics/1430) + +DANGER: **DANGER** +Features not on this list, or with **No** in the **Replicated** column, +are not replicated on the **secondary** node. Failing over without manually +replicating data from those features will cause the data to be **lost**. +If you wish to use those features on a **secondary** node, or to execute a failover +successfully, you must replicate their data using some other means. + +| Feature | Replicated | Verified | Notes | +|-----------------------------------------------------|--------------------------|-----------------------------|---------------------------------------------| +| Application data in PostgreSQL | **Yes** | **Yes** | | +| Project repository | **Yes** | **Yes** | | +| Project wiki repository | **Yes** | **Yes** | | +| Project designs repository | **Yes** | [No][design-verification] | | +| Uploads | **Yes** | [No][upload-verification] | Verified only on transfer, or manually (*1*)| +| LFS objects | **Yes** | [No][lfs-verification] | Verified only on transfer, or manually (*1*)| +| CI job artifacts (other than traces) | **Yes** | [No][artifact-verification] | Verified only manually (*1*) | +| Archived traces | **Yes** | [No][artifact-verification] | Verified only on transfer, or manually (*1*)| +| Personal snippets | **Yes** | **Yes** | | +| Project snippets | **Yes** | **Yes** | | +| Object pools for forked project deduplication | **Yes** | No | | +| [Server-side Git Hooks][custom-hooks] | No | No | | +| [Elasticsearch integration][elasticsearch] | No | No | | +| [GitLab Pages][gitlab-pages] | [No][pages-replication] | No | | +| [Container Registry][container-registry] | **Yes** | No | | +| [NPM Registry][npm-registry] | No | No | | +| [Maven Repository][maven-repository] | No | No | | +| [Conan Repository][conan-repository] | No | No | | +| [External merge request diffs][merge-request-diffs] | [No][diffs-replication] | No | | +| Content in object storage | **Yes** | No | | + +- (*1*): The integrity can be verified manually using + [Integrity Check Rake Task](../../raketasks/check.md) on both nodes and comparing the output between them. + +[design-replication]: https://gitlab.com/groups/gitlab-org/-/epics/1633 +[design-verification]: https://gitlab.com/gitlab-org/gitlab/issues/32467 +[upload-verification]: https://gitlab.com/groups/gitlab-org/-/epics/1817 +[lfs-verification]: https://gitlab.com/gitlab-org/gitlab/issues/8922 +[artifact-verification]: https://gitlab.com/gitlab-org/gitlab/issues/8923 +[diffs-replication]: https://gitlab.com/gitlab-org/gitlab/issues/33817 +[pages-replication]: https://gitlab.com/groups/gitlab-org/-/epics/589 + +[custom-hooks]: ../../custom_hooks.md +[elasticsearch]: ../../../integration/elasticsearch.md +[gitlab-pages]: ../../pages/index.md +[container-registry]: ../../packages/container_registry.md +[npm-registry]: ../../../user/packages/npm_registry/index.md +[maven-repository]: ../../../user/packages/maven_repository/index.md +[conan-repository]: ../../../user/packages/conan_repository/index.md +[merge-request-diffs]: ../../merge_request_diffs.md diff --git a/doc/administration/geo/replication/external_database.md b/doc/administration/geo/replication/external_database.md index 4451d3c6c081395c017eea2d53ea133ceda85112..6948dcc0c680ecc84124d5258df3d9f22574da63 100644 --- a/doc/administration/geo/replication/external_database.md +++ b/doc/administration/geo/replication/external_database.md @@ -13,13 +13,13 @@ developed and tested. We aim to be compatible with most external 1. SSH into a GitLab **primary** application server and login as root: - ```sh + ```bash sudo -i ``` 1. Execute the command below to define the node as **primary** node: - ```sh + ```bash gitlab-ctl set-geo-primary-node ``` @@ -47,7 +47,7 @@ configures the **primary** node's database to be replicated by making changes to `pg_hba.conf` and `postgresql.conf`. Make the following configuration changes manually to your external database configuration: -``` +```plaintext ## ## Geo Primary Role ## - pg_hba.conf @@ -55,7 +55,7 @@ manually to your external database configuration: host replication gitlab_replicator <trusted secondary IP>/32 md5 ``` -``` +```plaintext ## ## Geo Primary Role ## - postgresql.conf @@ -75,7 +75,7 @@ hot_standby = on Make the following configuration changes manually to your `postgresql.conf` of external replica database: -``` +```plaintext ## ## Geo Secondary Role ## - postgresql.conf diff --git a/doc/administration/geo/replication/high_availability.md b/doc/administration/geo/replication/high_availability.md index faa9d0511076cb0b5d521130f970445b9145d14a..19266a6b358f0b37f7962263c02b8edf0bc37fb0 100644 --- a/doc/administration/geo/replication/high_availability.md +++ b/doc/administration/geo/replication/high_availability.md @@ -123,6 +123,20 @@ a single node only, rather than as a PostgreSQL cluster. Configure the [**secondary** database](database.md) as a read-only replica of the **primary** database. Use the following as a guide. +1. Generate an MD5 hash of the desired password for the database user that the + GitLab application will use to access the read-replica database: + + Note that the username (`gitlab` by default) is incorporated into the hash. + + ```sh + gitlab-ctl pg-password-md5 gitlab + # Enter password: <your_password_here> + # Confirm password: <your_password_here> + # fca0b89a972d69f00eb3ec98a5838484 + ``` + + Use this hash to fill in `<md5_hash_of_your_password>` in the next step. + 1. Edit `/etc/gitlab/gitlab.rb` in the replica database machine, and add the following: @@ -167,6 +181,22 @@ only a single machine, rather than as a PostgreSQL cluster. Configure the tracking database. +1. Generate an MD5 hash of the desired password for the database user that the + GitLab application will use to access the tracking database: + + Note that the username (`gitlab_geo` by default) is incorporated into the + hash. + + ```sh + gitlab-ctl pg-password-md5 gitlab_geo + # Enter password: <your_password_here> + # Confirm password: <your_password_here> + # fca0b89a972d69f00eb3ec98a5838484 + ``` + + Use this hash to fill in `<tracking_database_password_md5_hash>` in the next + step. + 1. Edit `/etc/gitlab/gitlab.rb` in the tracking database machine, and add the following: diff --git a/doc/administration/geo/replication/img/adding_a_secondary_node.png b/doc/administration/geo/replication/img/adding_a_secondary_node.png index 5421b5786726d4a905d0992ff39c3fcdef0f1a9c..e33b690da1865be30cc895e12d86269c2710375d 100644 Binary files a/doc/administration/geo/replication/img/adding_a_secondary_node.png and b/doc/administration/geo/replication/img/adding_a_secondary_node.png differ diff --git a/doc/administration/geo/replication/img/single_git_add_geolocation_rule.png b/doc/administration/geo/replication/img/single_git_add_geolocation_rule.png index 4b04ba8d1f17ebb2c312705cc3c7eadb96d0d2ce..0d1b12e925f861bb3a434f365debd89224680c83 100644 Binary files a/doc/administration/geo/replication/img/single_git_add_geolocation_rule.png and b/doc/administration/geo/replication/img/single_git_add_geolocation_rule.png differ diff --git a/doc/administration/geo/replication/img/single_git_add_traffic_policy_endpoints.png b/doc/administration/geo/replication/img/single_git_add_traffic_policy_endpoints.png index c19ad57c953928d0cb3dd54488c501fd68fef582..4dfb78986da95e123d1ebc88c7339dfedb1b58ee 100644 Binary files a/doc/administration/geo/replication/img/single_git_add_traffic_policy_endpoints.png and b/doc/administration/geo/replication/img/single_git_add_traffic_policy_endpoints.png differ diff --git a/doc/administration/geo/replication/img/single_git_clone_panel.png b/doc/administration/geo/replication/img/single_git_clone_panel.png index 8aa0bd2f7d8869fd14739e0d66a47b2b27b1d02b..427224f5b780e27802721c2621cdde09a33826d7 100644 Binary files a/doc/administration/geo/replication/img/single_git_clone_panel.png and b/doc/administration/geo/replication/img/single_git_clone_panel.png differ diff --git a/doc/administration/geo/replication/img/single_git_create_policy_records_with_traffic_policy.png b/doc/administration/geo/replication/img/single_git_create_policy_records_with_traffic_policy.png index a554532f3b8fb7f43ac71c980217fe20ef7776de..ecc4859ca17914da94b219848708813b95ace953 100644 Binary files a/doc/administration/geo/replication/img/single_git_create_policy_records_with_traffic_policy.png and b/doc/administration/geo/replication/img/single_git_create_policy_records_with_traffic_policy.png differ diff --git a/doc/administration/geo/replication/img/single_git_created_policy_record.png b/doc/administration/geo/replication/img/single_git_created_policy_record.png index 74c42395e15e8bafea0ca8999d63f7131de8860d..f541c0dd23606c4763ce2eb4554d0fc89be79ddc 100644 Binary files a/doc/administration/geo/replication/img/single_git_created_policy_record.png and b/doc/administration/geo/replication/img/single_git_created_policy_record.png differ diff --git a/doc/administration/geo/replication/img/single_git_name_policy.png b/doc/administration/geo/replication/img/single_git_name_policy.png index 1a976539e9484f81aa51e67dfa8e65360f5969b2..5571a41cb3c1bf5af53840957a3f38e16afa7cbb 100644 Binary files a/doc/administration/geo/replication/img/single_git_name_policy.png and b/doc/administration/geo/replication/img/single_git_name_policy.png differ diff --git a/doc/administration/geo/replication/img/single_git_policy_diagram.png b/doc/administration/geo/replication/img/single_git_policy_diagram.png index d62952dbbb3350ec19f5318147fbc62eb139d131..eacd4de0e290bc4fdf75c2319ca101f1bb2cce77 100644 Binary files a/doc/administration/geo/replication/img/single_git_policy_diagram.png and b/doc/administration/geo/replication/img/single_git_policy_diagram.png differ diff --git a/doc/administration/geo/replication/img/single_git_traffic_policies.png b/doc/administration/geo/replication/img/single_git_traffic_policies.png index b3193c23d99976c8ef4e8fe4ca82fa5a986c42e9..197b0ac182ff13ca53a3c6f357a9964e87a14e66 100644 Binary files a/doc/administration/geo/replication/img/single_git_traffic_policies.png and b/doc/administration/geo/replication/img/single_git_traffic_policies.png differ diff --git a/doc/administration/geo/replication/index.md b/doc/administration/geo/replication/index.md index 0d2ca9ea9d98b15689ce4c379e8b16a3ea191464..04f61775b29e9af47ab6973e9d7cd34f2cf2f850 100644 --- a/doc/administration/geo/replication/index.md +++ b/doc/administration/geo/replication/index.md @@ -252,74 +252,13 @@ This list of limitations only reflects the latest version of GitLab. If you are ### Limitations on replication/verification -The following table lists the GitLab features along with their replication -and verification status on a **secondary** node. - You can keep track of the progress to implement the missing items in these epics/issues: - [Unreplicated Data Types](https://gitlab.com/groups/gitlab-org/-/epics/893) - [Verify all replicated data](https://gitlab.com/groups/gitlab-org/-/epics/1430) -| Feature | Replicated | Verified | Notes | -|-----------------------------------------------------|--------------------------|-----------------------------|--------------------------------------------| -| All database content | **Yes** | **Yes** | | -| Project repository | **Yes** | **Yes** | | -| Project wiki repository | **Yes** | **Yes** | | -| Project designs repository | **Yes** | [No][design-verification] | Behind feature flag (2) | -| Uploads | **Yes** | [No][upload-verification] | Verified only on transfer, or manually (1) | -| LFS Objects | **Yes** | [No][lfs-verification] | Verified only on transfer, or manually (1) | -| CI job artifacts (other than traces) | **Yes** | [No][artifact-verification] | Verified only manually (1) | -| Archived traces | **Yes** | [No][artifact-verification] | Verified only on transfer, or manually (1) | -| Personal snippets | **Yes** | **Yes** | | -| Version-controlled personal snippets | No | No | [Not yet supported][unsupported-snippets] | -| Project snippets | **Yes** | **Yes** | | -| Version-controlled project snippets | No | No | [Not yet supported][unsupported-snippets] | -| Object pools for forked project deduplication | **Yes** | No | | -| [Server-side Git Hooks][custom-hooks] | No | No | | -| [Elasticsearch integration][elasticsearch] | No | No | | -| [GitLab Pages][gitlab-pages] | [No][pages-replication] | No | | -| [Container Registry][container-registry] | **Yes** | No | | -| [NPM Registry][npm-registry] | No | No | | -| [Maven Repository][maven-repository] | No | No | | -| [Conan Repository][conan-repository] | No | No | | -| [External merge request diffs][merge-request-diffs] | [No][diffs-replication] | No | | -| Content in object storage | **Yes** | No | | - -[design-replication]: https://gitlab.com/groups/gitlab-org/-/epics/1633 -[design-verification]: https://gitlab.com/gitlab-org/gitlab/issues/32467 -[upload-verification]: https://gitlab.com/groups/gitlab-org/-/epics/1817 -[lfs-verification]: https://gitlab.com/gitlab-org/gitlab/issues/8922 -[artifact-verification]: https://gitlab.com/gitlab-org/gitlab/issues/8923 -[diffs-replication]: https://gitlab.com/gitlab-org/gitlab/issues/33817 -[pages-replication]: https://gitlab.com/groups/gitlab-org/-/epics/589 - -[unsupported-snippets]: https://gitlab.com/gitlab-org/gitlab/issues/14228 -[custom-hooks]: ../../custom_hooks.md -[elasticsearch]: ../../../integration/elasticsearch.md -[gitlab-pages]: ../../pages/index.md -[container-registry]: ../../packages/container_registry.md -[npm-registry]: ../../../user/packages/npm_registry/index.md -[maven-repository]: ../../../user/packages/maven_repository/index.md -[conan-repository]: ../../../user/packages/conan_repository/index.md -[merge-request-diffs]: ../../merge_request_diffs.md - -1. The integrity can be verified manually using -[Integrity Check Rake Task](../../raketasks/check.md) -on both nodes and comparing the output between them. -1. Enable the `enable_geo_design_sync` feature flag by running the -following in a Rails console: - - ```ruby - Feature.disable(:enable_geo_design_sync) - ``` - -DANGER: **DANGER** -Features not on this list, or with **No** in the **Replicated** column, -are not replicated on the **secondary** node. Failing over without manually -replicating data from those features will cause the data to be **lost**. -If you wish to use those features on a **secondary** node, or to execute a failover -successfully, you must replicate their data using some other means. +There is a complete list of all GitLab [data types](datatypes.md) and [existing support for replication and verification](datatypes.md#limitations-on-replicationverification). ## Frequently Asked Questions diff --git a/doc/administration/geo/replication/troubleshooting.md b/doc/administration/geo/replication/troubleshooting.md index 0a2602261d1f71405d4986cd017864b26245ad90..7c36d55027ad54274d262156b26ae58979047c79 100644 --- a/doc/administration/geo/replication/troubleshooting.md +++ b/doc/administration/geo/replication/troubleshooting.md @@ -51,6 +51,7 @@ Checking Geo ... GitLab Geo is available ... yes GitLab Geo is enabled ... yes +This machine's Geo node name matches a database record ... yes, found a secondary node named "Shanghai" GitLab Geo secondary database is correctly configured ... yes Database replication enabled? ... yes Database replication working? ... yes @@ -115,34 +116,36 @@ Any **secondary** nodes should point only to read-only instances. #### Can Geo detect the current node correctly? -Geo finds the current machine's name in `/etc/gitlab/gitlab.rb` by: +Geo finds the current machine's Geo node name in `/etc/gitlab/gitlab.rb` by: - Using the `gitlab_rails['geo_node_name']` setting. - If that is not defined, using the `external_url` setting. -To get a machine's name, run: - -```sh -sudo gitlab-rails runner "puts GeoNode.current_node_name" -``` - This name is used to look up the node with the same **Name** in **Admin Area > Geo**. -To check if current machine is correctly finding its node: +To check if the current machine has a node name that matches a node in the +database, run the check task: ```sh -sudo gitlab-rails runner "puts Gitlab::Geo.current_node.inspect" +sudo gitlab-rake gitlab:geo:check ``` -and expect something like: +It displays the current machine's node name and whether the matching database +record is a **primary** or **secondary** node. -```ruby -#<GeoNode id: 2, schema: "https", host: "gitlab.example.com", port: 443, relative_url_root: "", primary: false, ...> +``` +This machine's Geo node name matches a database record ... yes, found a secondary node named "Shanghai" ``` -By running the command above, `primary` should be `true` when executed in -the **primary** node, and `false` on any **secondary** node. +``` +This machine's Geo node name matches a database record ... no + Try fixing it: + You could add or update a Geo node database record, setting the name to "https://example.com/". + Or you could set this machine's Geo node name to match the name of an existing database record: "London", "Shanghai" + For more information see: + doc/administration/geo/replication/troubleshooting.md#can-geo-detect-the-current-node-correctly +``` ## Fixing errors found when running the Geo check rake task @@ -208,9 +211,9 @@ sudo gitlab-rake gitlab:geo:check Checking Geo ... Finished ``` - - Ensure that you have added the secondary node in the admin area of the primary node. + - Ensure that you have added the secondary node in the Admin Area of the primary node. - Ensure that you entered the `external_url` or `gitlab_rails['geo_node_name']` when adding the secondary node in the admin are of the primary node. - - Prior to GitLab 12.4, edit the secondary node in the admin area of the primary node and ensure that there is a trailing `/` in the `Name` field. + - Prior to GitLab 12.4, edit the secondary node in the Admin Area of the primary node and ensure that there is a trailing `/` in the `Name` field. 1. Check returns Exception: PG::UndefinedTable: ERROR: relation "geo_nodes" does not exist @@ -295,7 +298,7 @@ log data to build up in `pg_xlog`. Removing the unused slots can reduce the amou 1. Start a PostgreSQL console session: ```sh - sudo gitlab-psql gitlabhq_production + sudo gitlab-psql ``` Note: **Note:** Using `gitlab-rails dbconsole` will not work, because managing replication slots requires superuser permissions. @@ -318,6 +321,40 @@ Slots where `active` is `f` are not active. SELECT pg_drop_replication_slot('<name_of_extra_slot>'); ``` +### Message: "ERROR: canceling statement due to conflict with recovery" + +This error may rarely occur under normal usage, and the system is resilient +enough to recover. + +However, under certain conditions, some database queries on secondaries may run +excessively long, which increases the frequency of this error. At some point, +some of these queries will never be able to complete due to being canceled +every time. + +These long-running queries are +[planned to be removed in the future](https://gitlab.com/gitlab-org/gitlab/issues/34269), +but as a workaround, we recommend enabling +[hot_standby_feedback](https://www.postgresql.org/docs/10/hot-standby.html#HOT-STANDBY-CONFLICT). +This increases the likelihood of bloat on the **primary** node as it prevents +`VACUUM` from removing recently-dead rows. However, it has been used +successfully in production on GitLab.com. + +To enable `hot_standby_feedback`, add the following to `/etc/gitlab/gitlab.rb` +on the **secondary** node: + +```ruby +postgresql['hot_standby_feedback'] = 'on' +``` + +Then reconfigure GitLab: + +```sh +sudo gitlab-ctl reconfigure +``` + +To help us resolve this problem, consider commenting on +[the issue](https://gitlab.com/gitlab-org/gitlab/issues/4489). + ### Very large repositories never successfully synchronize on the **secondary** node GitLab places a timeout on all repository clones, including project imports @@ -457,16 +494,55 @@ The following steps are for Omnibus installs only. Using Geo with source-based i To check the configuration: +1. SSH into an app node in the **secondary**: + + ```sh + sudo -i + ``` + + Note: An app node is any machine running at least one of the following services: + + - `puma` + - `unicorn` + - `sidekiq` + - `geo-logcursor` + 1. Enter the database console: + If the tracking database is running on the same node: + ```sh gitlab-geo-psql ``` -1. Check whether any tables are present. If everything is working, you - should see something like this: + Or, if the tracking database is running on a different node, you must specify + the user and host when entering the database console: + + ```sh + gitlab-geo-psql -U gitlab_geo -h <IP of tracking database> + ``` + + You will be prompted for the password of the `gitlab_geo` user. You can find + it in plaintext in `/etc/gitlab/gitlab.rb` at: + + ```ruby + geo_secondary['db_password'] = '<geo_tracking_db_password>' + ``` + + This password is normally set on the tracking database during + [Step 3: Configure the tracking database on the secondary node](high_availability.md#step-3-configure-the-tracking-database-on-the-secondary-node), + and it is set on the app nodes during + [Step 4: Configure the frontend application servers on the secondary node](high_availability.md#step-4-configure-the-frontend-application-servers-on-the-secondary-node). + +1. Check whether any tables are present with the following statement: ```sql + SELECT * from information_schema.foreign_tables; + ``` + + If everything is working, you should see something like this: + + ``` gitlabhq_geo_production=# SELECT * from information_schema.foreign_tables; foreign_table_catalog | foreign_table_schema | foreign_table_name | foreign_server_catalog | foreign_server_name -------------------------+----------------------+-------------------------------------------------+-------------------------+--------------------- @@ -482,7 +558,7 @@ To check the configuration: 1. Check that the foreign server mapping is correct via `\des+`. The results should look something like this: - ```sql + ``` gitlabhq_geo_production=# \des+ List of foreign servers -[ RECORD 1 ]--------+------------------------------------------------------------ @@ -518,7 +594,7 @@ To check the configuration: 1. Check that the user mapping is configured properly via `\deu+`: - ```sql + ``` gitlabhq_geo_production=# \deu+ List of user mappings Server | User name | FDW Options diff --git a/doc/administration/geo/replication/version_specific_updates.md b/doc/administration/geo/replication/version_specific_updates.md index 5288cc6e186478169aae8b6a0b97284c38efd92d..7ce38d80c880f1256e6a88cb244eefa0aff60a8e 100644 --- a/doc/administration/geo/replication/version_specific_updates.md +++ b/doc/administration/geo/replication/version_specific_updates.md @@ -357,7 +357,7 @@ is prepended with the relevant node for better clarity: 1. **(secondary)** Save the snippet below in a file, let's say `/tmp/replica.sh`. Modify the embedded paths if necessary: - ``` + ```bash #!/bin/bash PORT="5432" diff --git a/doc/administration/git_protocol.md b/doc/administration/git_protocol.md index c1742ff87a746ee193f133b38e4b27a9caaee26a..436f1a55369e0746822bff19558825e157dd8fab 100644 --- a/doc/administration/git_protocol.md +++ b/doc/administration/git_protocol.md @@ -37,10 +37,9 @@ service is already configured to accept the `GIT_PROTOCOL` environment and users need not do anything more. For Omnibus GitLab and installations from source, you have to manually update -the SSH configuration of your server: +the SSH configuration of your server by adding the line below to the `/etc/ssh/sshd_config` file: -``` -# /etc/ssh/sshd_config +```plaintext AcceptEnv GIT_PROTOCOL ``` @@ -69,7 +68,7 @@ GIT_TRACE_CURL=1 git -c protocol.version=2 ls-remote https://your-gitlab-instanc You should see that the `Git-Protocol` header is sent: -``` +```plaintext 16:29:44.577888 http.c:657 => Send header: Git-Protocol: version=2 ``` @@ -105,7 +104,7 @@ GIT_SSH_COMMAND="ssh -v" git -c protocol.version=2 ls-remote ssh://your-gitlab-i You should see that the `GIT_PROTOCOL` environment variable is sent: -``` +```plaintext debug1: Sending env GIT_PROTOCOL = version=2 ``` diff --git a/doc/administration/gitaly/img/architecture_v12_4.png b/doc/administration/gitaly/img/architecture_v12_4.png index 1054083bb28000491f130e3769dac15f8593d936..6a3955a483bd9c3e8c4c7ff0bd6cf87e782423a5 100644 Binary files a/doc/administration/gitaly/img/architecture_v12_4.png and b/doc/administration/gitaly/img/architecture_v12_4.png differ diff --git a/doc/administration/gitaly/index.md b/doc/administration/gitaly/index.md index 9218ffa400658f176425fe07e624757f51e2b4bd..1aad0d80db4f0229d61d0a52f3805dda8610470b 100644 --- a/doc/administration/gitaly/index.md +++ b/doc/administration/gitaly/index.md @@ -38,19 +38,21 @@ This is an optional way to deploy Gitaly which can benefit GitLab installations that are larger than a single machine. Most installations will be better served with the default configuration used by Omnibus and the GitLab source installation guide. +Follow transition to Gitaly on its own server, [Gitaly servers will need to be upgraded before other servers in your cluster](https://docs.gitlab.com/omnibus/update/#upgrading-gitaly-servers). Starting with GitLab 11.4, Gitaly is able to serve all Git requests without requiring a shared NFS mount for Git repository data. Between 11.4 and 11.8 the exception was the [Elasticsearch indexer](https://gitlab.com/gitlab-org/gitlab-elasticsearch-indexer). But since 11.8 the indexer uses Gitaly for data access as well. NFS can still -be leveraged for redudancy on block level of the Git data. But only has to +be leveraged for redundancy on block level of the Git data. But only has to be mounted on the Gitaly server. -Starting with GitLab 11.8, it is possible to use Elasticsearch in conjunction with +From GitLab v11.8 to v12.2, it is possible to use Elasticsearch in conjunction with a Gitaly setup that isn't utilising NFS. In order to use Elasticsearch in this -scenario, the [new repository indexer](../../integration/elasticsearch.md#elasticsearch-repository-indexer-beta) -needs to be enabled in your GitLab configuration. +scenario, the [new repository indexer](../../integration/elasticsearch.md#elasticsearch-repository-indexer) +needs to be enabled in your GitLab configuration. [Since GitLab v12.3](https://gitlab.com/gitlab-org/gitlab/issues/6481), +the new indexer becomes the default and no configuration is required. NOTE: **Note:** While Gitaly can be used as a replacement for NFS, it's not recommended to use EFS as it may impact GitLab's performance. Review the [relevant documentation](../high_availability/nfs.md#avoid-using-awss-elastic-file-system-efs) @@ -162,11 +164,21 @@ Git operations in GitLab will result in an API error. postgresql['enable'] = false redis['enable'] = false nginx['enable'] = false - prometheus['enable'] = false unicorn['enable'] = false sidekiq['enable'] = false gitlab_workhorse['enable'] = false + # If you don't want to run monitoring services uncomment the following (not recommended) + # alertmanager['enable'] = false + # gitlab_exporter['enable'] = false + # grafana['enable'] = false + # node_exporter['enable'] = false + # prometheus['enable'] = false + + # Enable prometheus monitoring - comment out if you disable monitoring services above. + # This makes Prometheus listen on all interfaces. You must use firewalls to restrict access to this address/port. + prometheus['listen_address'] = '0.0.0.0:9090' + # Prevent database connections during 'gitlab-ctl reconfigure' gitlab_rails['rake_cache_clear'] = false gitlab_rails['auto_migrate'] = false @@ -189,9 +201,14 @@ Git operations in GitLab will result in an API error. 1. Append the following to `/etc/gitlab/gitlab.rb` for each respective server: + <!-- + updates to following example must also be made at + https://gitlab.com/gitlab-org/charts/gitlab/blob/master/doc/advanced/external-gitaly/external-omnibus-gitaly.md#configure-omnibus-gitlab + --> + On `gitaly1.internal`: - ``` + ```ruby git_data_dirs({ 'default' => { 'path' => '/var/opt/gitlab/git-data' @@ -204,7 +221,7 @@ Git operations in GitLab will result in an API error. On `gitaly2.internal`: - ``` + ```ruby git_data_dirs({ 'storage2' => { 'path' => '/srv/gitlab/git-data' @@ -502,7 +519,7 @@ To configure Gitaly with TLS: To observe what type of connections are actually being used in a production environment you can use the following Prometheus query: -``` +```prometheus sum(rate(gitaly_connections_total[5m])) by (type) ``` @@ -559,14 +576,14 @@ a few things that you need to do: 1. Make sure the [`git` user home directory](https://docs.gitlab.com/omnibus/settings/configuration.html#moving-the-home-directory-for-a-user) is on local disk. 1. Configure [database lookup of SSH keys](../operations/fast_ssh_key_lookup.md) - to eliminate the need for a shared authorized_keys file. + to eliminate the need for a shared `authorized_keys` file. 1. Configure [object storage for job artifacts](../job_artifacts.md#using-object-storage) including [incremental logging](../job_logs.md#new-incremental-logging-architecture). 1. Configure [object storage for LFS objects](../lfs/lfs_administration.md#storing-lfs-objects-in-remote-object-storage). 1. Configure [object storage for uploads](../uploads.md#using-object-storage-core-only). -1. Configure [object storage for Merge Request Diffs](../merge_request_diffs.md#using-object-storage). -1. Configure [object storage for Packages](../packages/index.md#using-object-storage) (Optional Feature). -1. Configure [object storage for Dependency Proxy](../packages/dependency_proxy.md#using-object-storage) (Optional Feature). +1. Configure [object storage for merge request diffs](../merge_request_diffs.md#using-object-storage). +1. Configure [object storage for packages](../packages/index.md#using-object-storage) (optional feature). +1. Configure [object storage for dependency proxy](../packages/dependency_proxy.md#using-object-storage) (optional feature). NOTE: **Note:** One current feature of GitLab that still requires a shared directory (NFS) is @@ -601,7 +618,7 @@ This will limit the number of in-flight RPC calls for the given RPC's. The limit is applied per repository. In the example above, each on the Gitaly server can have at most 20 simultaneous PostUploadPack calls in flight, and the same for SSHUploadPack. If another request comes in for -a repository that hase used up its 20 slots, that request will get +a repository that has used up its 20 slots, that request will get queued. You can observe the behavior of this queue via the Gitaly logs and via @@ -631,14 +648,14 @@ machine. Use Prometheus to see what the current authentication behavior of your GitLab installation is. -``` +```prometheus sum(rate(gitaly_authentications_total[5m])) by (enforced, status) ``` In a system where authentication is configured correctly, and where you have live traffic, you will see something like this: -``` +```prometheus {enforced="true",status="ok"} 4424.985419441742 ``` @@ -667,7 +684,7 @@ gitaly['auth_transitioning'] = true After you have applied this, your Prometheus query should return something like this: -``` +```prometheus {enforced="false",status="would be ok"} 4424.985419441742 ``` @@ -713,7 +730,7 @@ gitaly['auth_transitioning'] = false Refresh your Prometheus query. You should now see the same kind of result as you did in the beginning: -``` +```prometheus {enforced="true",status="ok"} 4424.985419441742 ``` @@ -745,7 +762,7 @@ Git implementation itself. Because Rugged+Unicorn was so efficient, GitLab's application code ended up with lots of duplicate Git object lookups (like looking up the -`master` commmit a dozen times in one request). We could write +`master` commit a dozen times in one request). We could write inefficient code without being punished for it. When we migrated these Git lookups to Gitaly calls, we were suddenly @@ -853,14 +870,14 @@ gitaly-debug -h ### Commits, pushes, and clones return a 401 -``` +```plaintext remote: GitLab: 401 Unauthorized ``` You will need to sync your `gitlab-secrets.json` file with your GitLab app nodes. -### Client side GRPC logs +### Client side gRPC logs Gitaly uses the [gRPC](https://grpc.io/) RPC framework. The Ruby gRPC client has its own log file which may contain useful information when @@ -885,7 +902,7 @@ Assuming your `grpc_client_handled_total` counter only observes Gitaly, the following query shows you RPCs are (most likely) internally implemented as calls to `gitaly-ruby`: -``` +```prometheus sum(rate(grpc_client_handled_total[5m])) by (grpc_method) > 0 ``` @@ -910,7 +927,7 @@ Confirm the following are all true: ``` - When any user adds or modifies a file from the repository using the GitLab - UI, it immediatley fails with a red `401 Unauthorized` banner. + UI, it immediately fails with a red `401 Unauthorized` banner. - Creating a new project and [initializing it with a README](../../gitlab-basics/create-project.md#blank-projects) successfully creates the project but doesn't create the README. - When [tailing the logs](https://docs.gitlab.com/omnibus/settings/logs.html#tail-logs-in-a-console-on-the-server) on an app node and reproducing the error, you get `401` errors diff --git a/doc/administration/gitaly/praefect.md b/doc/administration/gitaly/praefect.md index 6193a40ac4f6ade669a5ef427ed5049e5af8168d..72c3f996841ccedf05978815089c81e58065cc50 100644 --- a/doc/administration/gitaly/praefect.md +++ b/doc/administration/gitaly/praefect.md @@ -25,15 +25,22 @@ The most common architecture for Praefect is simplified in the diagram below: ```mermaid graph TB GitLab --> Praefect; - Praefect --> Gitaly-1; - Praefect --> Gitaly-2; - Praefect --> Gitaly-3; + Praefect --- PostgreSQL; + Praefect --> Gitaly1; + Praefect --> Gitaly2; + Praefect --> Gitaly3; ``` Where `GitLab` is the collection of clients that can request Git operations. -The Praefect node has threestorage nodes attached. Praefect itself doesn't +The Praefect node has three storage nodes attached. Praefect itself doesn't store data, but connects to three Gitaly nodes, `Gitaly-1`, `Gitaly-2`, and `Gitaly-3`. +In order to keep track of replication state, Praefect relies on a +PostgreSQL database. This database is a single point of failure so you +should use a highly available PostgreSQL server for this. GitLab +itself needs a HA PostgreSQL server too, so you could optionally co-locate the Praefect +SQL database on the PostgreSQL server you use for the rest of GitLab. + Praefect may be enabled on its own node or can be run on the GitLab server. In the example below we will use a separate server, but the optimal configuration for Praefect is still being determined. @@ -62,6 +69,53 @@ We need to manage the following secrets and make them match across hosts: `PRAEFECT_EXTERNAL_TOKEN` because Gitaly clients must not be able to access internal nodes of the Praefect cluster directly; that could lead to data loss. +1. `PRAEFECT_SQL_PASSWORD`: this password is used by Praefect to connect to + PostgreSQL. + +#### Network addresses + +1. `POSTGRESQL_SERVER`: the host name or IP address of your PostgreSQL server + +#### PostgreSQL + +To set up a Praefect cluster you need a highly available PostgreSQL +server. You need PostgreSQL 9.6 or newer. Praefect needs to have a SQL +user with the right to create databases. + +In the instructions below we assume you have administrative access to +your PostgreSQL server via `psql`. Depending on your environment, you +may also be able to do this via the web interface of your cloud +platform, or via your configuration management system, etc. + +Below we assume that you have administrative access as the `postgres` +user. First open a `psql` session as the `postgres` user: + +```shell +psql -h POSTGRESQL_SERVER -U postgres -d template1 +``` + +Once you are connected, run the following command. Replace +`PRAEFECT_SQL_PASSWORD` with the actual (random) password you +generated for the `praefect` SQL user: + +```sql +CREATE ROLE praefect WITH LOGIN CREATEDB PASSWORD 'PRAEFECT_SQL_PASSWORD'; +\q # exit psql +``` + +Now connect as the `praefect` user to create the database. This has +the side effect of verifying that you have access: + +```shell +psql -h POSTGRESQL_SERVER -U praefect -d template1 +``` + +Once you have connected as the `praefect` user, run: + +```sql +CREATE DATABASE praefect_production WITH ENCODING=UTF8; +\q # quit psql +``` #### Praefect @@ -118,10 +172,39 @@ praefect['virtual_storages'] = { } } } + +praefect['database_host'] = 'POSTGRESQL_SERVER' +praefect['database_port'] = 5432 +praefect['database_user'] = 'praefect' +praefect['database_password'] = 'PRAEFECT_SQL_PASSWORD' +praefect['database_dbname'] = 'praefect_production' + +# Uncomment the line below if you do not want to use an encrypted +# connection to PostgreSQL +# praefect['database_sslmode'] = 'disable' + +# Uncomment and modify these lines if you are using a TLS client +# certificate to connect to PostgreSQL +# praefect['database_sslcert'] = '/path/to/client-cert' +# praefect['database_sslkey'] = '/path/to/client-key' + +# Uncomment and modify this line if your PostgreSQL server uses a custom +# CA +# praefect['database_sslrootcert'] = '/path/to/rootcert' ``` Save the file and [reconfigure Praefect](../restart_gitlab.md#omnibus-gitlab-reconfigure). +After you reconfigure, verify that Praefect can reach PostgreSQL: + +```shell +sudo -u git /opt/gitlab/embedded/bin/praefect -config /var/opt/gitlab/praefect/config.toml sql-ping +``` + +If the check fails, make sure you have followed the steps correctly. If you edit `/etc/gitlab/gitlab.rb`, +remember to run `sudo gitlab-ctl reconfigure` again before trying the +`sql-ping` command. + #### Gitaly Next we will configure each Gitaly server assigned to Praefect. Configuration for these diff --git a/doc/administration/high_availability/README.md b/doc/administration/high_availability/README.md index d411fb7f20f00b05a82a50e276b0f9a72eb719e5..13b6bd88453186668b1e210fde1efb88b3b7cb8d 100644 --- a/doc/administration/high_availability/README.md +++ b/doc/administration/high_availability/README.md @@ -224,14 +224,9 @@ users are, how much automation you use, mirroring, and repo/change size. - **Supported Users (approximate):** 2,000 - **Test RPS Rates:** API: 40 RPS, Web: 4 RPS, Git: 4 RPS -- **Status:** Work-in-progress - **Known Issues:** For the latest list of known performance issues head [here](https://gitlab.com/gitlab-org/gitlab/issues?label_name%5B%5D=Quality%3Aperformance-issues). -NOTE: **Note:** This architecture is a work-in-progress of the work so far. The -Quality team will be certifying this environment in late 2019 or early 2020. The specifications -may be adjusted prior to certification based on performance testing. - | Service | Nodes | Configuration | GCP type | | ----------------------------|-------|-----------------------|---------------| | GitLab Rails <br> - Puma workers on each node set to 90% of available CPUs with 8 threads | 3 | 8 vCPU, 7.2GB Memory | n1-highcpu-8 | @@ -255,14 +250,9 @@ vendors a best effort like for like can be used. - **Supported Users (approximate):** 5,000 - **Test RPS Rates:** API: 100 RPS, Web: 10 RPS, Git: 10 RPS -- **Status:** Work-in-progress - **Known Issues:** For the latest list of known performance issues head [here](https://gitlab.com/gitlab-org/gitlab/issues?label_name%5B%5D=Quality%3Aperformance-issues). -NOTE: **Note:** This architecture is a work-in-progress of the work so far. The -Quality team will be certifying this environment in late 2019 or early 2020. The specifications -may be adjusted prior to certification based on performance testing. - | Service | Nodes | Configuration | GCP type | | ----------------------------|-------|-----------------------|---------------| | GitLab Rails <br> - Puma workers on each node set to 90% of available CPUs with 16 threads | 3 | 16 vCPU, 14.4GB Memory | n1-highcpu-16 | diff --git a/doc/administration/high_availability/consul.md b/doc/administration/high_availability/consul.md index 392b9b76c3189edd27fa02952e199db01709d2e6..71d380dbec7a8de9eab1ec84bc7fd47282b480d7 100644 --- a/doc/administration/high_availability/consul.md +++ b/doc/administration/high_availability/consul.md @@ -64,7 +64,7 @@ command to verify all server nodes are communicating: The output should be similar to: -``` +```plaintext Node Address Status Type Build Protocol DC CONSUL_NODE_ONE XXX.XXX.XXX.YYY:8301 alive server 0.9.2 2 gitlab_consul CONSUL_NODE_TWO XXX.XXX.XXX.YYY:8301 alive server 0.9.2 2 gitlab_consul @@ -80,8 +80,8 @@ check the [Troubleshooting section](#troubleshooting) before proceeding. To see which nodes are part of the cluster, run the following on any member in the cluster -``` -# /opt/gitlab/embedded/bin/consul members +```shell +$ /opt/gitlab/embedded/bin/consul members Node Address Status Type Build Protocol DC consul-b XX.XX.X.Y:8301 alive server 0.9.0 2 gitlab_consul consul-c XX.XX.X.Y:8301 alive server 0.9.0 2 gitlab_consul @@ -100,7 +100,7 @@ If it is necessary to restart the server cluster, it is important to do this in To be safe, we recommend you only restart one server agent at a time to ensure the cluster remains intact. -For larger clusters, it is possible to restart multiple agents at a time. See the [Consul consensus document](https://www.consul.io/docs/internals/consensus.html#deployment-table) for how many failures it can tolerate. This will be the number of simulateneous restarts it can sustain. +For larger clusters, it is possible to restart multiple agents at a time. See the [Consul consensus document](https://www.consul.io/docs/internals/consensus.html#deployment-table) for how many failures it can tolerate. This will be the number of simultaneous restarts it can sustain. ## Upgrades for bundled Consul @@ -127,7 +127,7 @@ By default, the server agents will attempt to [bind](https://www.consul.io/docs/ You will see messages like the following in `gitlab-ctl tail consul` output if you are running into this issue: -``` +```plaintext 2017-09-25_19:53:39.90821 2017/09/25 19:53:39 [WARN] raft: no known peers, aborting election 2017-09-25_19:53:41.74356 2017/09/25 19:53:41 [ERR] agent: failed to sync remote state: No cluster leader ``` @@ -154,7 +154,7 @@ In the case that a node has multiple private IPs the agent be confused as to whi You will see messages like the following in `gitlab-ctl tail consul` output if you are running into this issue: -``` +```plaintext 2017-11-09_17:41:45.52876 ==> Starting Consul agent... 2017-11-09_17:41:45.53057 ==> Error creating agent: Failed to get advertise address: Multiple private IPs found. Please configure one. ``` @@ -181,10 +181,10 @@ If you lost enough server agents in the cluster to break quorum, then the cluste By default, GitLab does not store anything in the Consul cluster that cannot be recreated. To erase the Consul database and reinitialize -``` -# gitlab-ctl stop consul -# rm -rf /var/opt/gitlab/consul/data -# gitlab-ctl start consul +```shell +gitlab-ctl stop consul +rm -rf /var/opt/gitlab/consul/data +gitlab-ctl start consul ``` After this, the cluster should start back up, and the server agents rejoin. Shortly after that, the client agents should rejoin as well. diff --git a/doc/administration/high_availability/database.md b/doc/administration/high_availability/database.md index 02684f575d49c4c48cbef64b8c014f2658e3c84a..8e57b0497306de1ea18da09aa8a3d4115e72046c 100644 --- a/doc/administration/high_availability/database.md +++ b/doc/administration/high_availability/database.md @@ -229,7 +229,7 @@ available database connections. In this document we are assuming 3 database nodes, which makes this configuration: -``` +```ruby postgresql['max_wal_senders'] = 4 ``` @@ -352,7 +352,7 @@ When installing the GitLab package, do not supply `EXTERNAL_URL` value. to inform `gitlab-ctl` that they are standby nodes initially and it need not attempt to register them as primary node - ``` + ```ruby # HA setting to specify if a node should attempt to be master on initialization repmgr['master_on_initialization'] = false ``` @@ -396,7 +396,7 @@ Select one node as a primary node. The output should be similar to the following: - ``` + ```plaintext Role | Name | Upstream | Connection String ----------+----------|----------|---------------------------------------- * master | HOSTNAME | | host=HOSTNAME user=gitlab_repmgr dbname=gitlab_repmgr @@ -442,7 +442,7 @@ Select one node as a primary node. The output should be similar to the following: - ``` + ```plaintext Role | Name | Upstream | Connection String ----------+---------|-----------|------------------------------------------------ * master | MASTER | | host=MASTER_NODE_NAME user=gitlab_repmgr dbname=gitlab_repmgr @@ -463,7 +463,7 @@ gitlab-ctl repmgr cluster show The output should be similar to: -``` +```plaintext Role | Name | Upstream | Connection String ----------+--------------|--------------|-------------------------------------------------------------------- * master | MASTER | | host=MASTER port=5432 user=gitlab_repmgr dbname=gitlab_repmgr @@ -652,7 +652,7 @@ On secondary nodes, edit `/etc/gitlab/gitlab.rb` and add all the configuration added to primary node, noted above. In addition, append the following configuration: -``` +```ruby # HA setting to specify if a node should attempt to be master on initialization repmgr['master_on_initialization'] = false ``` @@ -706,7 +706,7 @@ After deploying the configuration follow these steps: gitlab-psql -d gitlabhq_production ``` - ``` + ```shell CREATE EXTENSION pg_trgm; ``` @@ -804,7 +804,7 @@ consul['configuration'] = { On secondary nodes, edit `/etc/gitlab/gitlab.rb` and add all the information added to primary node, noted above. In addition, append the following configuration -``` +```ruby # HA setting to specify if a node should attempt to be master on initialization repmgr['master_on_initialization'] = false ``` @@ -908,7 +908,7 @@ after it has been restored to service. It will output something like: - ``` + ```plaintext 959789412 ``` @@ -1052,7 +1052,7 @@ Now there should not be errors. If errors still occur then there is another prob You may get this error when running `gitlab-rake gitlab:db:configure` or you may see the error in the PgBouncer log file. -``` +```plaintext PG::ConnectionBad: ERROR: pgbouncer cannot connect to server ``` @@ -1063,13 +1063,13 @@ You can confirm that this is the issue by checking the PostgreSQL log on the mas database node. If you see the following error then `trust_auth_cidr_addresses` is the problem. -``` +```plaintext 2018-03-29_13:59:12.11776 FATAL: no pg_hba.conf entry for host "123.123.123.123", user "pgbouncer", database "gitlabhq_production", SSL off ``` To fix the problem, add the IP address to `/etc/gitlab/gitlab.rb`. -``` +```ruby postgresql['trust_auth_cidr_addresses'] = %w(123.123.123.123/32 <other_cidrs>) ``` diff --git a/doc/administration/high_availability/gitlab.md b/doc/administration/high_availability/gitlab.md index 71ab169a8013d850fa937f92dab0675c557c0a5f..b4269cd4e38c3d588281d72fae4c892e84961381 100644 --- a/doc/administration/high_availability/gitlab.md +++ b/doc/administration/high_availability/gitlab.md @@ -11,7 +11,7 @@ these additional steps before proceeding with GitLab installation. 1. If necessary, install the NFS client utility packages using the following commands: - ``` + ```shell # Ubuntu/Debian apt-get install nfs-common @@ -24,7 +24,7 @@ these additional steps before proceeding with GitLab installation. to configure your NFS server. See [NFS documentation](nfs.md) for the various options. Here is an example snippet to add to `/etc/fstab`: - ``` + ```plaintext 10.1.0.1:/var/opt/gitlab/.ssh /var/opt/gitlab/.ssh nfs4 defaults,soft,rsize=1048576,wsize=1048576,noatime,nofail,lookupcache=positive 0 2 10.1.0.1:/var/opt/gitlab/gitlab-rails/uploads /var/opt/gitlab/gitlab-rails/uploads nfs4 defaults,soft,rsize=1048576,wsize=1048576,noatime,nofail,lookupcache=positive 0 2 10.1.0.1:/var/opt/gitlab/gitlab-rails/shared /var/opt/gitlab/gitlab-rails/shared nfs4 defaults,soft,rsize=1048576,wsize=1048576,noatime,nofail,lookupcache=positive 0 2 @@ -35,7 +35,7 @@ these additional steps before proceeding with GitLab installation. 1. Create the shared directories. These may be different depending on your NFS mount locations. - ``` + ```shell mkdir -p /var/opt/gitlab/.ssh /var/opt/gitlab/gitlab-rails/uploads /var/opt/gitlab/gitlab-rails/shared /var/opt/gitlab/gitlab-ci/builds /var/opt/gitlab/git-data ``` diff --git a/doc/administration/high_availability/nfs.md b/doc/administration/high_availability/nfs.md index f7c5593e2115584f7d1a8b05e5c92db83f7f4c90..1d0dc420987c78fd0f1305f9a163b4af55862ce9 100644 --- a/doc/administration/high_availability/nfs.md +++ b/doc/administration/high_availability/nfs.md @@ -132,7 +132,7 @@ For supported database architecture, please see our documentation on Below is an example of an NFS mount point defined in `/etc/fstab` we use on GitLab.com: -``` +```plaintext 10.1.1.1:/var/opt/gitlab/git-data /var/opt/gitlab/git-data nfs4 defaults,soft,rsize=1048576,wsize=1048576,noatime,nofail,lookupcache=positive 0 2 ``` @@ -149,7 +149,7 @@ Note there are several options that you should consider using: It's recommended to nest all GitLab data dirs within a mount, that allows automatic restore of backups without manually moving existing data. -``` +```plaintext mountpoint └── gitlab-data ├── builds diff --git a/doc/administration/high_availability/pgbouncer.md b/doc/administration/high_availability/pgbouncer.md index 09b33c3554a21bfd70fc2936652a4df21600359a..7b93159628d89ff21f39df3347589f53af18d87c 100644 --- a/doc/administration/high_availability/pgbouncer.md +++ b/doc/administration/high_availability/pgbouncer.md @@ -83,7 +83,7 @@ In a HA setup, it's recommended to run a PgBouncer node separately for each data The output should be similar to the following: - ``` + ```plaintext name | host | port | database | force_user | pool_size | reserve_pool | pool_mode | max_connections | current_connections ---------------------+-------------+------+---------------------+------------+-----------+--------------+-----------+-----------------+--------------------- gitlabhq_production | MASTER_HOST | 5432 | gitlabhq_production | | 20 | 0 | | 0 | 0 @@ -102,7 +102,7 @@ If you're running more than one PgBouncer node as recommended, then at this time As an example here's how you could do it with [HAProxy](https://www.haproxy.org/): -``` +```plaintext global log /dev/log local0 log localhost local1 notice diff --git a/doc/administration/high_availability/redis.md b/doc/administration/high_availability/redis.md index 9b733034f5be5bd5ce9ceb0ece3c46562c62d5db..8e94b56a94055c9c074440ab8b767b8d1329005c 100644 --- a/doc/administration/high_availability/redis.md +++ b/doc/administration/high_availability/redis.md @@ -391,7 +391,7 @@ The prerequisites for a HA Redis setup are the following: prevent database migrations from running on upgrade, add the following configuration to your `/etc/gitlab/gitlab.rb` file: - ``` + ```ruby gitlab_rails['auto_migrate'] = false ``` @@ -439,7 +439,7 @@ The prerequisites for a HA Redis setup are the following: 1. To prevent reconfigure from running automatically on upgrade, run: - ``` + ```shell sudo touch /etc/gitlab/skip-auto-reconfigure ``` @@ -569,7 +569,7 @@ multiple machines with the Sentinel daemon. 1. To prevent database migrations from running on upgrade, run: - ``` + ```shell sudo touch /etc/gitlab/skip-auto-reconfigure ``` @@ -898,14 +898,14 @@ Before proceeding with the troubleshooting below, check your firewall rules: You can check if everything is correct by connecting to each server using `redis-cli` application, and sending the `info replication` command as below. -``` +```shell /opt/gitlab/embedded/bin/redis-cli -h <redis-host-or-ip> -a '<redis-password>' info replication ``` When connected to a `master` Redis, you will see the number of connected `slaves`, and a list of each with connection details: -``` +```plaintext # Replication role:master connected_slaves:1 @@ -920,7 +920,7 @@ repl_backlog_histlen:1048576 When it's a `slave`, you will see details of the master connection and if its `up` or `down`: -``` +```plaintext # Replication role:slave master_host:10.133.1.58 @@ -959,7 +959,7 @@ To make sure your configuration is correct: 1. SSH into your GitLab application server 1. Enter the Rails console: - ``` + ```shell # For Omnibus installations sudo gitlab-rails console @@ -985,7 +985,7 @@ To make sure your configuration is correct: 1. Then back in the Rails console from the first step, run: - ``` + ```ruby redis.info ``` diff --git a/doc/administration/housekeeping.md b/doc/administration/housekeeping.md index 9083619841eccde58aef0bddfe8d104248ae744e..ca3480f114663507feb1aaba08be87ae628e4403 100644 --- a/doc/administration/housekeeping.md +++ b/doc/administration/housekeeping.md @@ -5,14 +5,13 @@ ## Automatic housekeeping GitLab automatically runs `git gc` and `git repack` on repositories -after Git pushes. If needed you can change how often this happens, or -to turn it off, go to **Admin area > Settings** -(`/admin/application_settings`). +after Git pushes. You can change how often this happens or turn it off in +**Admin Area > Settings > Repository** (`/admin/application_settings/repository`). ## Manual housekeeping -The housekeeping function will run a `repack` or `gc` depending on the -"Automatic Git repository housekeeping" settings configured in **Admin area > Settings** +The housekeeping function runs `repack` or `gc` depending on the +**Housekeeping** settings configured in **Admin Area > Settings > Repository**. For example in the following scenario a `git repack -d` will be executed: diff --git a/doc/administration/img/repository_storages_admin_ui.png b/doc/administration/img/repository_storages_admin_ui.png index 315b4b0144c55a51538ff941825c5ee248157ab7..51b2f5f8c1521616e6c36556f08afa6ba7f00b45 100644 Binary files a/doc/administration/img/repository_storages_admin_ui.png and b/doc/administration/img/repository_storages_admin_ui.png differ diff --git a/doc/administration/incoming_email.md b/doc/administration/incoming_email.md index a0360f1d252e9b1ec2efc84fa61ba71dedb4475c..1550787d532df992630e0f136100f9a09f2eb878 100644 --- a/doc/administration/incoming_email.md +++ b/doc/administration/incoming_email.md @@ -7,7 +7,7 @@ GitLab has several features based on receiving incoming emails: - [New issue by email](../user/project/issues/managing_issues.md#new-issue-via-email): allow GitLab users to create a new issue by sending an email to a user-specific email address. -- [New merge request by email](../user/project/merge_requests/creating_merge_requests.md#create-new-merge-requests-by-email): +- [New merge request by email](../user/project/merge_requests/creating_merge_requests.md#new-merge-request-by-email-core-only): allow GitLab users to create a new merge request by sending an email to a user-specific email address. - [Service Desk](../user/project/service_desk.md): provide e-mail support to @@ -79,7 +79,7 @@ email address in order to sign up. If you also host a public-facing GitLab instance at `hooli.com` and set your incoming email domain to `hooli.com`, an attacker could abuse the "Create new issue by email" or -"[Create new merge request by email](../user/project/merge_requests/creating_merge_requests.md#create-new-merge-requests-by-email)" +"[Create new merge request by email](../user/project/merge_requests/creating_merge_requests.md#new-merge-request-by-email-core-only)" features by using a project's unique address as the email when signing up for Slack, which would send a confirmation email, which would create a new issue or merge request on the project owned by the attacker, allowing them to click the diff --git a/doc/administration/index.md b/doc/administration/index.md index 2a9980cddb33ec0f2c168c8aa9e5076df4b78d88..8172acd09b4736d4c950c461628b35fb5f10e4c0 100644 --- a/doc/administration/index.md +++ b/doc/administration/index.md @@ -119,7 +119,7 @@ Learn how to install, configure, update, and maintain your GitLab instance. - [Auditor users](auditor_users.md): Users with read-only access to all projects, groups, and other resources on the GitLab instance. **(PREMIUM ONLY)** - [Incoming email](incoming_email.md): Configure incoming emails to allow users to [reply by email](reply_by_email.md), create [issues by email](../user/project/issues/managing_issues.md#new-issue-via-email) and - [merge requests by email](../user/project/merge_requests/creating_merge_requests.md#create-new-merge-requests-by-email), and to enable [Service Desk](../user/project/service_desk.md). + [merge requests by email](../user/project/merge_requests/creating_merge_requests.md#new-merge-request-by-email-core-only), and to enable [Service Desk](../user/project/service_desk.md). - [Postfix for incoming email](reply_by_email_postfix_setup.md): Set up a basic Postfix mail server with IMAP authentication on Ubuntu for incoming emails. @@ -200,6 +200,7 @@ Learn how to install, configure, update, and maintain your GitLab instance. - [Log system](logs.md): Where to look for logs. - [Sidekiq Troubleshooting](troubleshooting/sidekiq.md): Debug when Sidekiq appears hung and is not processing jobs. - [Troubleshooting Elasticsearch](troubleshooting/elasticsearch.md) +- [GitLab application limits](instance_limits.md) ### Support Team Docs diff --git a/doc/administration/instance_limits.md b/doc/administration/instance_limits.md new file mode 100644 index 0000000000000000000000000000000000000000..d68b825ed887ff9718b483ebae4234ed1483267b --- /dev/null +++ b/doc/administration/instance_limits.md @@ -0,0 +1,44 @@ +--- +type: reference +--- + +# GitLab application limits + +GitLab, like most large applications, enforces limits within certain features to maintain a +minimum quality of performance. Allowing some features to be limitless could affect security, +performance, data, or could even exhaust the allocated resources for the application. + +## Number of comments per issue, merge request or commit + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/22388) in GitLab 12.4. + +There's a limit to the number of comments that can be submitted on an issue, +merge request, or commit. When the limit is reached, system notes can still be +added so that the history of events is not lost, but user-submitted comments +will fail. + +- **Max limit:** 5.000 comments + +## Number of pipelines per Git push + +> [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/issues/51401) in GitLab 11.10. + +The number of pipelines that can be created in a single push is 4. +This is to prevent the accidental creation of pipelines when `git push --all` +or `git push --mirror` is used. + +Read more in the [CI documentation](../ci/yaml/README.md#processing-git-pushes). + +## Retention of activity history + +> [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/issues/21164) in GitLab 8.12. + +Activity history for projects and individuals' profiles was limited to one year until [GitLab 11.4](https://gitlab.com/gitlab-org/gitlab-foss/issues/52246) when it was extended to two years, and in [GitLab 12.4](https://gitlab.com/gitlab-org/gitlab/issues/33840) to three years. + +## Number of project webhooks + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/merge_requests/20730) in GitLab 12.6. + +A maximum number of project webhooks applies to each GitLab.com tier. Check the +[Maximum number of webhooks (per tier)](../user/project/integrations/webhooks.md#maximum-number-of-webhooks-per-tier) +section in the Webhooks page. diff --git a/doc/administration/integration/terminal.md b/doc/administration/integration/terminal.md index 1af15648b97e10af4d6c5708b24a27a6213b1554..42acc4cb80ed1280a1bc1b47384bf9c37f40cb7b 100644 --- a/doc/administration/integration/terminal.md +++ b/doc/administration/integration/terminal.md @@ -96,6 +96,6 @@ they will receive a `Connection failed` message. in GitLab 8.17. Terminal sessions use long-lived connections; by default, these may last -forever. You can configure a maximum session time in the Admin area of your +forever. You can configure a maximum session time in the Admin Area of your GitLab instance if you find this undesirable from a scalability or security point of view. diff --git a/doc/administration/job_artifacts.md b/doc/administration/job_artifacts.md index ec2f40700f5bc3c5ee30cee9a6a92a4b8c534290..7a3d116ea588858324803f56610d00f86625c3ec 100644 --- a/doc/administration/job_artifacts.md +++ b/doc/administration/job_artifacts.md @@ -267,7 +267,7 @@ you can flip the feature flag from a Rails console. ## Set the maximum file size of the artifacts Provided the artifacts are enabled, you can change the maximum file size of the -artifacts through the [Admin area settings](../user/admin_area/settings/continuous_integration.md#maximum-artifacts-size-core-only). +artifacts through the [Admin Area settings](../user/admin_area/settings/continuous_integration.md#maximum-artifacts-size-core-only). ## Storage statistics @@ -294,3 +294,149 @@ memory and disk I/O. [reconfigure gitlab]: restart_gitlab.md#omnibus-gitlab-reconfigure "How to reconfigure Omnibus GitLab" [restart gitlab]: restart_gitlab.md#installations-from-source "How to restart GitLab" [gitlab workhorse]: https://gitlab.com/gitlab-org/gitlab-workhorse "GitLab Workhorse repository" + +## Troubleshooting + +### Job artifacts using too much disk space + +Job artifacts can fill up your disk space quicker than expected. Some possible +reasons are: + +- Users have configured job artifacts expiration to be longer than necessary. +- The number of jobs run, and hence artifacts generated, is higher than expected. +- Job logs are larger than expected, and have accumulated over time. + +In these and other cases, you'll need to identify the projects most responsible +for disk space usage, figure out what types of artifacts are using the most +space, and in some cases, manually delete job artifacts to reclaim disk space. + +#### List projects by total size of job artifacts stored + +List the top 20 projects, sorted by the total size of job artifacts stored, by +running the following code in the Rails console (`sudo gitlab-rails console`): + +```ruby +include ActionView::Helpers::NumberHelper +ProjectStatistics.order(build_artifacts_size: :desc).limit(20).each do |s| + puts "#{number_to_human_size(s.build_artifacts_size)} \t #{s.project.full_path}" +end +``` + +You can change the number of projects listed by modifying `.limit(20)` to the +number you want. + +#### List largest artifacts in a single project + +List the 50 largest job artifacts in a single project by running the following +code in the Rails console (`sudo gitlab-rails console`): + +```ruby +include ActionView::Helpers::NumberHelper +project = Project.find_by_full_path('path/to/project') +Ci::JobArtifact.where(project: project).order(size: :desc).limit(50).map { |a| puts "ID: #{a.id} - #{a.file_type}: #{number_to_human_size(a.size)}" } +``` + +You can change the number of job artifacts listed by modifying `.limit(50)` to +the number you want. + +#### Delete job artifacts from jobs completed before a specific date + +CAUTION: **CAUTION:** +These commands remove data permanently from the database and from disk. We +highly recommend running them only under the guidance of a Support Engineer, or +running them in a test environment with a backup of the instance ready to be +restored, just in case. + +If you need to manually remove job artifacts associated with multiple jobs while +**retaining their job logs**, this can be done from the Rails console (`sudo gitlab-rails console`): + +1. Select jobs to be deleted: + + To select all jobs with artifacts for a single project: + + ```ruby + project = Project.find_by_full_path('path/to/project') + builds_with_artifacts = project.builds.with_artifacts_archive + ``` + + To select all jobs with artifacts across the entire GitLab instance: + + ```ruby + builds_with_artifacts = Ci::Build.with_artifacts_archive + ``` + +1. Delete job artifacts older than a specific date: + + NOTE: **NOTE:** + This step will also erase artifacts that users have chosen to + ["keep"](../user/project/pipelines/job_artifacts.html#browsing-artifacts). + + ```ruby + builds_to_clear = builds_with_artifacts.where("finished_at < ?", 1.week.ago) + builds_to_clear.find_each do |build| + build.artifacts_expire_at = Time.now + build.erase_erasable_artifacts! + end + ``` + + `1.week.ago` is a Rails `ActiveSupport::Duration` method which calculates a new + date or time in the past. Other valid examples are: + + - `7.days.ago` + - `3.months.ago` + - `1.year.ago` + +#### Delete job artifacts and logs from jobs completed before a specific date + +CAUTION: **CAUTION:** +These commands remove data permanently from the database and from disk. We +highly recommend running them only under the guidance of a Support Engineer, or +running them in a test environment with a backup of the instance ready to be +restored, just in case. + +If you need to manually remove ALL job artifacts associated with multiple jobs, +**including job logs**, this can be done from the Rails console (`sudo gitlab-rails console`): + +1. Select jobs to be deleted: + + To select jobs with artifacts for a single project: + + ```ruby + project = Project.find_by_full_path('path/to/project') + builds_with_artifacts = project.builds.with_existing_job_artifacts + ``` + + To select jobs with artifacts across the entire GitLab instance: + + ```ruby + builds_with_artifacts = Ci::Build.with_existing_job_artifacts + ``` + +1. Select the user which will be mentioned in the web UI as erasing the job: + + ```ruby + admin_user = User.find_by(username: 'username') + ``` + +1. Erase job artifacts and logs older than a specific date: + + ```ruby + builds_to_clear = builds_with_artifacts.where("finished_at < ?", 1.week.ago) + builds_to_clear.find_each do |build| + print "Ci::Build ID #{build.id}... " + + if build.erasable? + build.erase(erased_by: admin_user) + puts "Erased" + else + puts "Skipped (Nothing to erase or not erasable)" + end + end + ``` + + `1.week.ago` is a Rails `ActiveSupport::Duration` method which calculates a new + date or time in the past. Other valid examples are: + + - `7.days.ago` + - `3.months.ago` + - `1.year.ago` diff --git a/doc/administration/job_logs.md b/doc/administration/job_logs.md index 6042786d1017e4bcb8f4638a44217abf5f6adf3d..fc37fbb170d1fed09131522e4b8387486a2824fa 100644 --- a/doc/administration/job_logs.md +++ b/doc/administration/job_logs.md @@ -81,7 +81,7 @@ with one change: _the stored path of the first two phases is different_. This in log architecture stores chunks of logs in Redis and a persistent store (object storage or database) instead of file storage. Redis is used as first-class storage, and it stores up-to 128KB of data. Once the full chunk is sent, it is flushed to a persistent store, either object storage (temporary directory) or database. -After a while, the data in Redis and a persitent store will be archived to [object storage](#uploading-logs-to-object-storage). +After a while, the data in Redis and a persistent store will be archived to [object storage](#uploading-logs-to-object-storage). The data are stored in the following Redis namespace: `Gitlab::Redis::SharedState`. diff --git a/doc/administration/lfs/lfs_administration.md b/doc/administration/lfs/lfs_administration.md index f3b8029f487847c8a9168debb7c34b6a543fb32a..fbf48619854c9f1972b0733d866ef4f9c3b6bcef 100644 --- a/doc/administration/lfs/lfs_administration.md +++ b/doc/administration/lfs/lfs_administration.md @@ -238,8 +238,8 @@ and [projects APIs](../../api/projects.md). ## Troubleshooting: `Google::Apis::TransmissionError: execution expired` -If LFS integration is configred with Google Cloud Storage and background uploads (`background_upload: true` and `direct_upload: false`), -Sidekiq workers may encouter this error. This is because the uploading timed out with very large files. +If LFS integration is configured with Google Cloud Storage and background uploads (`background_upload: true` and `direct_upload: false`), +Sidekiq workers may encounter this error. This is because the uploading timed out with very large files. LFS files up to 6Gb can be uploaded without any extra steps, otherwise you need to use the following workaround. ```shell diff --git a/doc/administration/logs.md b/doc/administration/logs.md index f4a1c7542525960a9128cb61353d0a9499d0a21e..c69f787a5d88a67542c2c08ffe13cecb0e4a9888 100644 --- a/doc/administration/logs.md +++ b/doc/administration/logs.md @@ -96,7 +96,7 @@ request that have been performed and how much time it took. This task is more useful for GitLab contributors and developers. Use part of this log file when you are going to report bug. For example: -``` +```plaintext Started GET "/gitlabhq/yaml_db/tree/master" for 168.111.56.1 at 2015-02-12 19:34:53 +0200 Processing by Projects::TreeController#show as HTML Parameters: {"project_id"=>"gitlabhq/yaml_db", "id"=>"master"} @@ -151,7 +151,7 @@ installations from source. It helps you discover events happening in your instance such as user creation, project removing and so on. For example: -``` +```plaintext October 06, 2014 11:56: User "Administrator" (admin@example.com) was created October 06, 2014 11:56: Documentcloud created a new project "Documentcloud / Underscore" October 06, 2014 11:56: Gitlab Org created a new project "Gitlab Org / Gitlab Ce" @@ -167,7 +167,7 @@ installations from source. It contains information about [integrations](../user/project/integrations/project_services.md) activities such as Jira, Asana and Irker services. It uses JSON format like the example below: -``` json +```json {"severity":"ERROR","time":"2018-09-06T14:56:20.439Z","service_class":"JiraService","project_id":8,"project_path":"h5bp/html5-boilerplate","message":"Error sending message","client_url":"http://jira.gitlap.com:8080","error":"execution expired"} {"severity":"INFO","time":"2018-09-06T17:15:16.365Z","service_class":"JiraService","project_id":3,"project_path":"namespace2/project2","message":"Successfully posted","client_url":"http://jira.example.com"} ``` @@ -249,7 +249,7 @@ Instead of the format above, you can opt to generate JSON logs for Sidekiq. For example: ```json -{"severity":"INFO","time":"2018-04-03T22:57:22.071Z","queue":"cronjob:update_all_mirrors","args":[],"class":"UpdateAllMirrorsWorker","retry":false,"queue_namespace":"cronjob","jid":"06aeaa3b0aadacf9981f368e","created_at":"2018-04-03T22:57:21.930Z","enqueued_at":"2018-04-03T22:57:21.931Z","pid":10077,"message":"UpdateAllMirrorsWorker JID-06aeaa3b0aadacf9981f368e: done: 0.139 sec","job_status":"done","duration":0.139,"completed_at":"2018-04-03T22:57:22.071Z"} +{"severity":"INFO","time":"2018-04-03T22:57:22.071Z","queue":"cronjob:update_all_mirrors","args":[],"class":"UpdateAllMirrorsWorker","retry":false,"queue_namespace":"cronjob","jid":"06aeaa3b0aadacf9981f368e","created_at":"2018-04-03T22:57:21.930Z","enqueued_at":"2018-04-03T22:57:21.931Z","pid":10077,"message":"UpdateAllMirrorsWorker JID-06aeaa3b0aadacf9981f368e: done: 0.139 sec","job_status":"done","duration":0.139,"completed_at":"2018-04-03T22:57:22.071Z","db_duration":0.05,"db_duration_s":0.0005,"gitaly_duration":0,"gitaly_calls":0} ``` For Omnibus GitLab installations, add the configuration option: @@ -276,7 +276,7 @@ installations from source. GitLab Shell is used by GitLab for executing Git commands and provide SSH access to Git repositories. For example: -``` +```plaintext I, [2015-02-13T06:17:00.671315 #9291] INFO -- : Adding project root/example.git at </var/opt/gitlab/git-data/repositories/root/dcdcdcdcd.git>. I, [2015-02-13T06:17:00.679433 #9291] INFO -- : Moving existing hooks directory and symlinking global hooks directory for /var/opt/gitlab/git-data/repositories/root/example.git. ``` @@ -294,7 +294,7 @@ serving the GitLab application. You can look at this log if, for example, your application does not respond. This log contains all information about the state of Unicorn processes at any given time. -``` +```plaintext I, [2015-02-13T06:14:46.680381 #9047] INFO -- : Refreshing Gem list I, [2015-02-13T06:14:56.931002 #9047] INFO -- : listening on addr=127.0.0.1:8080 fd=12 I, [2015-02-13T06:14:56.931381 #9047] INFO -- : listening on addr=/var/opt/gitlab/gitlab-rails/sockets/gitlab.socket fd=13 @@ -421,6 +421,47 @@ etc. For example: {"severity":"DEBUG","time":"2019-10-17T06:23:13.227Z","correlation_id":null,"message":"redacted_search_result","class_name":"Milestone","id":2,"ability":"read_milestone","current_user_id":2,"query":"project"} ``` +## `exceptions_json.log` + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/merge_requests/17819) in GitLab 12.6. + +This file lives in +`/var/log/gitlab/gitlab-rails/exceptions_json.log` for Omnibus GitLab +packages or in `/home/git/gitlab/log/exceptions_json.log` for installations +from source. + +It logs the information about exceptions being tracked by `Gitlab::ErrorTracking` which provides standard and consistent way of [processing rescued exceptions](https://gitlab.com/gitlab-org/gitlab/blob/master/doc/development/logging.md#exception-handling). + +Each line contains a JSON line that can be ingested by Elasticsearch. For example: + +```json +{ + "severity": "ERROR", + "time": "2019-12-17T11:49:29.485Z", + "correlation_id": "AbDVUrrTvM1", + "extra.server": { + "os": { + "name": "Darwin", + "version": "Darwin Kernel Version 19.2.0", + "build": "19.2.0", + }, + "runtime": { + "name": "ruby", + "version": "ruby 2.6.5p114 (2019-10-01 revision 67812) [x86_64-darwin18]" + } + }, + "extra.project_id": 55, + "extra.relation_key": "milestones", + "extra.relation_index": 1, + "exception.class": "NoMethodError", + "exception.message": "undefined method `strong_memoize' for #<Gitlab::ImportExport::RelationFactory:0x00007fb5d917c4b0>", + "exception.backtrace": [ + "lib/gitlab/import_export/relation_factory.rb:329:in `unique_relation?'", + "lib/gitlab/import_export/relation_factory.rb:345:in `find_or_create_object!'" + ] +} +``` + [repocheck]: repository_checks.md [Rack Attack]: ../security/rack_attack.md [Rate Limit]: ../user/admin_area/settings/rate_limits_on_raw_endpoints.md diff --git a/doc/administration/monitoring/gitlab_instance_administration_project/index.md b/doc/administration/monitoring/gitlab_instance_administration_project/index.md index b07bbafaf7dc763dc75bb5edad8834744d5b52d4..8675521ddb181b555934f30c008bda1498a4dd9e 100644 --- a/doc/administration/monitoring/gitlab_instance_administration_project/index.md +++ b/doc/administration/monitoring/gitlab_instance_administration_project/index.md @@ -1,7 +1,9 @@ # GitLab instance administration project NOTE: **Note:** -This feature is not yet available and is [planned for 12.6](https://gitlab.com/gitlab-org/gitlab/issues/32351). +This feature is available behind a feature flag called `self_monitoring_project` +since [12.7](https://gitlab.com/gitlab-org/gitlab/issues/32351). The feature flag +will be removed once we [add dashboards to display metrics](https://gitlab.com/groups/gitlab-org/-/epics/2367). GitLab has been adding the ability for administrators to see insights into the health of their GitLab instance. In order to surface this experience in a native way, similar to how diff --git a/doc/administration/monitoring/performance/gitlab_configuration.md b/doc/administration/monitoring/performance/gitlab_configuration.md index 9a25cc04ee775b47ab15692136c00fcad232058e..1bff170768a3bd97a116ae7a0ea702122c370bb1 100644 --- a/doc/administration/monitoring/performance/gitlab_configuration.md +++ b/doc/administration/monitoring/performance/gitlab_configuration.md @@ -1,8 +1,12 @@ # GitLab Configuration +CAUTION: **InfluxDB is deprecated in favor of Prometheus:** +InfluxDB support is scheduled to be removed in GitLab 13.0. +You are advised to use [Prometheus](../prometheus/index.md) instead. + GitLab Performance Monitoring is disabled by default. To enable it and change any of its -settings, navigate to the Admin area in **Settings > Metrics** -(`/admin/application_settings`). +settings, navigate to **Admin Area > Settings > Metrics and profiling** +(`/admin/application_settings/metrics_and_profiling`). The minimum required settings you need to set are the InfluxDB host and port. Make sure _Enable InfluxDB Metrics_ is checked and hit **Save** to save the @@ -28,7 +32,7 @@ have been performed. Read more on: -- [Introduction to GitLab Performance Monitoring](introduction.md) +- [Introduction to GitLab Performance Monitoring](index.md) - [InfluxDB Configuration](influxdb_configuration.md) - [InfluxDB Schema](influxdb_schema.md) - [Grafana Install/Configuration](grafana_configuration.md) diff --git a/doc/administration/monitoring/performance/grafana_configuration.md b/doc/administration/monitoring/performance/grafana_configuration.md index ccba0a554793034fa8b5f2eaf15c14d72b69a0fb..2fbbeb0b774bd104cade279d62bbe31700f3cc52 100644 --- a/doc/administration/monitoring/performance/grafana_configuration.md +++ b/doc/administration/monitoring/performance/grafana_configuration.md @@ -1,5 +1,9 @@ # Grafana Configuration +CAUTION: **InfluxDB is deprecated in favor of Prometheus:** +InfluxDB support is scheduled to be removed in GitLab 13.0. +You are advised to use [Prometheus](../prometheus/index.md) instead. + [Grafana](https://grafana.com/) is a tool that allows you to visualize time series metrics through graphs and dashboards. It supports several backend data stores, including InfluxDB. GitLab writes performance data to InfluxDB @@ -53,14 +57,14 @@ repository. To use this repository you must first clone it: -``` +```shell git clone https://gitlab.com/gitlab-org/influxdb-management.git cd influxdb-management ``` Next you must install the required dependencies: -``` +```shell gem install bundler bundle install ``` @@ -109,14 +113,14 @@ repository for more information on this process. If you have set up Grafana, you can enable a link to access it easily from the sidebar: -1. Go to the admin area under **Settings > Metrics and profiling** - and expand "Metrics - Grafana". +1. Go to the **Admin Area > Settings > Metrics and profiling**. +1. Expand **Metrics - Grafana**. 1. Check the "Enable access to Grafana" checkbox. 1. If Grafana is enabled through Omnibus GitLab and on the same server, leave "Grafana URL" unchanged. In any other case, enter the full URL path of the Grafana instance. 1. Click **Save changes**. -1. The new link will be available in the admin area under **Monitoring > Metrics Dashboard**. +1. The new link will be available in the **Admin Area > Monitoring > Metrics Dashboard**. ## Security Update @@ -135,7 +139,7 @@ echo "0" > /var/opt/gitlab/grafana/CVE_reset_status To reinstate your old data, move it back into its original location: -``` +```shell sudo mv /var/opt/gitlab/grafana/data.bak.xxxx/ /var/opt/gitlab/grafana/data/ ``` @@ -152,7 +156,7 @@ For more information and further mitigation details, please refer to our [blog p Read more on: -- [Introduction to GitLab Performance Monitoring](introduction.md) +- [Introduction to GitLab Performance Monitoring](index.md) - [GitLab Configuration](gitlab_configuration.md) - [InfluxDB Installation/Configuration](influxdb_configuration.md) - [InfluxDB Schema](influxdb_schema.md) diff --git a/doc/administration/monitoring/performance/img/performance_bar.png b/doc/administration/monitoring/performance/img/performance_bar.png index d206d5a4268f563d6e8183d17531b1a2574c084d..be06e0b2f94122eb532e220244874d43af9363bc 100644 Binary files a/doc/administration/monitoring/performance/img/performance_bar.png and b/doc/administration/monitoring/performance/img/performance_bar.png differ diff --git a/doc/administration/monitoring/performance/img/performance_bar_frontend.png b/doc/administration/monitoring/performance/img/performance_bar_frontend.png index 489f855fe334cf173b0a0ff4fc1eb01ac6844924..32a241e032b652656f912eea3090e2b5f53e0d06 100644 Binary files a/doc/administration/monitoring/performance/img/performance_bar_frontend.png and b/doc/administration/monitoring/performance/img/performance_bar_frontend.png differ diff --git a/doc/administration/monitoring/performance/img/performance_bar_gitaly_threshold.png b/doc/administration/monitoring/performance/img/performance_bar_gitaly_threshold.png index d4bf5c69ffa44323881cf62225372cb1d6197b39..4e42d904cdf1cfd672048b1ec01b7cf263917cf3 100644 Binary files a/doc/administration/monitoring/performance/img/performance_bar_gitaly_threshold.png and b/doc/administration/monitoring/performance/img/performance_bar_gitaly_threshold.png differ diff --git a/doc/administration/monitoring/performance/img/performance_bar_request_selector_warning.png b/doc/administration/monitoring/performance/img/performance_bar_request_selector_warning.png index 966549554a4bba09eda2ca0353ef85fd1903cf11..74711387ffe8de22ae92228304d5fde296a9e73b 100644 Binary files a/doc/administration/monitoring/performance/img/performance_bar_request_selector_warning.png and b/doc/administration/monitoring/performance/img/performance_bar_request_selector_warning.png differ diff --git a/doc/administration/monitoring/performance/img/performance_bar_request_selector_warning_expanded.png b/doc/administration/monitoring/performance/img/performance_bar_request_selector_warning_expanded.png index 3362186bb484d092b02d376aed020987b4176411..36553f513e19198977062e94d8b0718b342ab1bd 100644 Binary files a/doc/administration/monitoring/performance/img/performance_bar_request_selector_warning_expanded.png and b/doc/administration/monitoring/performance/img/performance_bar_request_selector_warning_expanded.png differ diff --git a/doc/administration/monitoring/performance/index.md b/doc/administration/monitoring/performance/index.md index 5204ab40dc9503689e87f941340c106a9307b26e..6569f6a8c6d42f1d2eca1ae6fc96f8a3bd8dabea 100644 --- a/doc/administration/monitoring/performance/index.md +++ b/doc/administration/monitoring/performance/index.md @@ -1,5 +1,9 @@ # GitLab Performance Monitoring +CAUTION: **InfluxDB is deprecated in favor of Prometheus:** +InfluxDB support is scheduled to be removed in GitLab 13.0. +You are advised to use [Prometheus](../prometheus/index.md) instead. + GitLab comes with its own application performance measuring system as of GitLab 8.4, simply called "GitLab Performance Monitoring". GitLab Performance Monitoring is available in both the Community and Enterprise editions. diff --git a/doc/administration/monitoring/performance/influxdb_configuration.md b/doc/administration/monitoring/performance/influxdb_configuration.md index f1f588a924d6e7a353d0872fba8767f461033f0a..b18be09ef4b0c21c0967a26a698c190cfb5f64cd 100644 --- a/doc/administration/monitoring/performance/influxdb_configuration.md +++ b/doc/administration/monitoring/performance/influxdb_configuration.md @@ -1,5 +1,9 @@ # InfluxDB Configuration +CAUTION: **InfluxDB is deprecated in favor of Prometheus:** +InfluxDB support is scheduled to be removed in GitLab 13.0. +You are advised to use [Prometheus](../prometheus/index.md) instead. + The default settings provided by [InfluxDB] are not sufficient for a high traffic GitLab environment. The settings discussed in this document are based on the settings GitLab uses for GitLab.com, depending on your own needs you may need to @@ -44,7 +48,7 @@ upcoming InfluxDB releases. Make sure you have the following in your configuration file: -``` +```toml [data] dir = "/var/lib/influxdb/data" engine = "tsm1" @@ -56,7 +60,7 @@ Production environments should have the InfluxDB admin panel **disabled**. This feature can be disabled by adding the following to your InfluxDB configuration file: -``` +```toml [admin] enabled = false ``` @@ -67,7 +71,7 @@ HTTP is required when using the [InfluxDB CLI] or other tools such as Grafana, thus it should be enabled. When enabling make sure to _also_ enable authentication: -``` +```toml [http] enabled = true auth-enabled = true @@ -81,7 +85,7 @@ admin user](#create-a-new-admin-user)._ GitLab writes data to InfluxDB via UDP and thus this must be enabled. Enabling UDP can be done using the following settings: -``` +```toml [[udp]] enabled = true bind-address = ":8089" @@ -134,7 +138,7 @@ allowing traffic from members of said VLAN. If you want to [enable authentication](#http), you might want to [create an admin user][influx-admin]: -``` +```shell influx -execute "CREATE USER jeff WITH PASSWORD '1234' WITH ALL PRIVILEGES" ``` @@ -164,7 +168,7 @@ influx -execute 'SHOW DATABASES' The output should be similar to: -``` +```plaintext name: databases --------------- name @@ -178,7 +182,7 @@ That's it! Now your GitLab instance should send data to InfluxDB. Read more on: -- [Introduction to GitLab Performance Monitoring](introduction.md) +- [Introduction to GitLab Performance Monitoring](index.md) - [GitLab Configuration](gitlab_configuration.md) - [InfluxDB Schema](influxdb_schema.md) - [Grafana Install/Configuration](grafana_configuration.md) diff --git a/doc/administration/monitoring/performance/influxdb_schema.md b/doc/administration/monitoring/performance/influxdb_schema.md index eff0e29f58d5e856d762d6cc815b73b09b5806c3..adbccdaaeb8c73f25f8898661cd0af441c7897e8 100644 --- a/doc/administration/monitoring/performance/influxdb_schema.md +++ b/doc/administration/monitoring/performance/influxdb_schema.md @@ -1,5 +1,9 @@ # InfluxDB Schema +CAUTION: **InfluxDB is deprecated in favor of Prometheus:** +InfluxDB support is scheduled to be removed in GitLab 13.0. +You are advised to use [Prometheus](../prometheus/index.md) instead. + The following measurements are currently stored in InfluxDB: - `PROCESS_file_descriptors` @@ -39,7 +43,7 @@ while the method name is stored in the tag `method`. The tag `action` contains the full name of the transaction action. Both the `method` and `action` fields are in the following format: -``` +```plaintext ClassName#method_name ``` @@ -91,7 +95,7 @@ Depending on the event type additional tags may be available as well. Read more on: -- [Introduction to GitLab Performance Monitoring](introduction.md) +- [Introduction to GitLab Performance Monitoring](index.md) - [GitLab Configuration](gitlab_configuration.md) - [InfluxDB Configuration](influxdb_configuration.md) - [Grafana Install/Configuration](grafana_configuration.md) diff --git a/doc/administration/monitoring/performance/performance_bar.md b/doc/administration/monitoring/performance/performance_bar.md index 98c611ea140482b6e8ed3983bd4727b3a44b03f5..fe4c29fbb018ec9e33ae4ba67436e80eb8e73021 100644 --- a/doc/administration/monitoring/performance/performance_bar.md +++ b/doc/administration/monitoring/performance/performance_bar.md @@ -52,8 +52,9 @@ And requests with warnings are indicated in the request selector with a ## Enable the Performance Bar via the Admin panel GitLab Performance Bar is disabled by default. To enable it for a given group, -navigate to the Admin area in **Settings > Metrics and Profiling > Profiling - Performance bar** -(`admin/application_settings/metrics_and_profiling`). +navigate to **Admin Area > Settings > Metrics and profiling** +(`admin/application_settings/metrics_and_profiling`), and expand the section +**Profiling - Performance bar**. The only required setting you need to set is the full path of the group that will be allowed to display the Performance Bar. diff --git a/doc/administration/monitoring/prometheus/gitlab_metrics.md b/doc/administration/monitoring/prometheus/gitlab_metrics.md index 570480594766d2e344d5ecfd0f1bc06778da9ce2..f3da5a6dd2f4b30271d485cff6c5147d069d6825 100644 --- a/doc/administration/monitoring/prometheus/gitlab_metrics.md +++ b/doc/administration/monitoring/prometheus/gitlab_metrics.md @@ -6,8 +6,8 @@ installations from source you'll have to configure it yourself. To enable the GitLab Prometheus metrics: -1. Log into GitLab as an administrator, and go to the Admin area. -1. Navigate to GitLab's **Settings > Metrics and profiling**. +1. Log into GitLab as an administrator. +1. Navigate to **Admin Area > Settings > Metrics and profiling**. 1. Find the **Metrics - Prometheus** section, and click **Enable Prometheus Metrics**. 1. [Restart GitLab](../../restart_gitlab.md#omnibus-gitlab-restart) for the changes to take effect. @@ -26,7 +26,7 @@ The following metrics are available: | Metric | Type | Since | Description | Labels | |:---------------------------------------------------------------|:----------|-----------------------:|:----------------------------------------------------------------------------------------------------|:----------------------------------------------------| | `gitlab_banzai_cached_render_real_duration_seconds` | Histogram | 9.4 | Duration of rendering Markdown into HTML when cached output exists | controller, action | -| `gitlab_banzai_cacheless_render_real_duration_seconds` | Histogram | 9.4 | Duration of rendering Markdown into HTML when cached outupt does not exist | controller, action | +| `gitlab_banzai_cacheless_render_real_duration_seconds` | Histogram | 9.4 | Duration of rendering Markdown into HTML when cached output does not exist | controller, action | | `gitlab_cache_misses_total` | Counter | 10.2 | Cache read miss | controller, action | | `gitlab_cache_operation_duration_seconds` | Histogram | 10.2 | Cache access time | | | `gitlab_cache_operations_total` | Counter | 12.2 | Cache operations by controller/action | controller, action, operation | @@ -59,7 +59,7 @@ The following metrics are available: | `gitlab_transaction_event_push_commit_total` | Counter | 9.4 | Counter for commits | branch | | `gitlab_transaction_event_push_tag_total` | Counter | 9.4 | Counter for tag pushes | | | `gitlab_transaction_event_rails_exception_total` | Counter | 9.4 | Counter for number of rails exceptions | | -| `gitlab_transaction_event_receive_email_total` | Counter | 9.4 | Counter for recieved emails | handler | +| `gitlab_transaction_event_receive_email_total` | Counter | 9.4 | Counter for received emails | handler | | `gitlab_transaction_event_remote_mirrors_failed_total` | Counter | 10.8 | Counter for failed remote mirrors | | | `gitlab_transaction_event_remote_mirrors_finished_total` | Counter | 10.8 | Counter for finished remote mirrors | | | `gitlab_transaction_event_remote_mirrors_running_total` | Counter | 10.8 | Counter for running remote mirrors | | @@ -154,10 +154,10 @@ Some basic Ruby runtime metrics are available: | `ruby_sampler_duration_seconds` | Counter | 11.1 | Time spent collecting stats | | `ruby_process_cpu_seconds_total` | Gauge | 12.0 | Total amount of CPU time per process | | `ruby_process_max_fds` | Gauge | 12.0 | Maximum number of open file descriptors per process | -| `ruby_process_resident_memory_bytes` | Gauge | 12.0 | Memory usage by process, measured in bytes | +| `ruby_process_resident_memory_bytes` | Gauge | 12.0 | Memory usage by process | | `ruby_process_start_time_seconds` | Gauge | 12.0 | UNIX timestamp of process start time | -[GC.stat]: https://ruby-doc.org/core-2.6.3/GC.html#method-c-stat +[GC.stat]: https://ruby-doc.org/core-2.6.5/GC.html#method-c-stat ## Unicorn Metrics diff --git a/doc/administration/monitoring/prometheus/index.md b/doc/administration/monitoring/prometheus/index.md index eb7a2d791c1305944e87ced29e4bbffd0848a287..62bacf9791e39c2c1ffcdf02d9c8ebf0c84214c7 100644 --- a/doc/administration/monitoring/prometheus/index.md +++ b/doc/administration/monitoring/prometheus/index.md @@ -245,7 +245,7 @@ To add a Prometheus dashboard for a single server GitLab setup: 1. Create a new data source in Grafana. 1. Name your data source i.e GitLab. -1. Select `Prometheus` in the type drop down. +1. Select `Prometheus` in the type dropdown box. 1. Add your Prometheus listen address as the URL and set access to `Browser`. 1. Set the HTTP method to `GET`. 1. Save & Test your configuration to verify that it works. diff --git a/doc/administration/operations/cleaning_up_redis_sessions.md b/doc/administration/operations/cleaning_up_redis_sessions.md index fd469ae23e3a19f491a6dd52aae4712651e45d3f..38fac8a0eca98504a76f3a3f722bc3b375ec9e7f 100644 --- a/doc/administration/operations/cleaning_up_redis_sessions.md +++ b/doc/administration/operations/cleaning_up_redis_sessions.md @@ -22,7 +22,7 @@ settings outlined in First we define a shell function with the proper Redis connection details. -``` +```shell rcli() { # This example works for Omnibus installations of GitLab 7.3 or newer. For an # installation from source you will have to change the socket path and the @@ -37,7 +37,7 @@ rcli ping Now we do a search to see if there are any session keys in the old format for us to clean up. -``` +```shell # returns the number of old-format session keys in Redis rcli keys '*' | grep '^[a-f0-9]\{32\}$' | wc -l ``` @@ -45,7 +45,7 @@ rcli keys '*' | grep '^[a-f0-9]\{32\}$' | wc -l If the number is larger than zero, you can proceed to expire the keys from Redis. If the number is zero there is nothing to clean up. -``` +```shell # Tell Redis to expire each matched key after 600 seconds. rcli keys '*' | grep '^[a-f0-9]\{32\}$' | awk '{ print "expire", $0, 600 }' | rcli # This will print '(integer) 1' for each key that gets expired. diff --git a/doc/administration/operations/extra_sidekiq_processes.md b/doc/administration/operations/extra_sidekiq_processes.md index e15f91ebab2459df7e53e97e09a2ea67526d4c50..1be89f759da3bf15264f0929914bade0be9d6e4c 100644 --- a/doc/administration/operations/extra_sidekiq_processes.md +++ b/doc/administration/operations/extra_sidekiq_processes.md @@ -59,8 +59,8 @@ To start extra Sidekiq processes, you must enable `sidekiq-cluster`: sudo gitlab-ctl reconfigure ``` -Once the extra Sidekiq processes are added, you can visit the "Background Jobs" -section under the admin area in GitLab (`/admin/background_jobs`). +Once the extra Sidekiq processes are added, you can visit the +**Admin Area > Monitoring > Background Jobs** (`/admin/background_jobs`) in GitLab.  diff --git a/doc/administration/operations/fast_ssh_key_lookup.md b/doc/administration/operations/fast_ssh_key_lookup.md index 9a38e8ddd23065e4c7f16e3d53aa0ca224a702ba..96571b0a5d97f01f17c891609093d10e3ec5ed74 100644 --- a/doc/administration/operations/fast_ssh_key_lookup.md +++ b/doc/administration/operations/fast_ssh_key_lookup.md @@ -53,7 +53,7 @@ Add the following to your `sshd_config` file. This is usually located at `/etc/ssh/sshd_config`, but it will be `/assets/sshd_config` if you're using Omnibus Docker: -``` +```plaintext AuthorizedKeysCommand /opt/gitlab/embedded/service/gitlab-shell/bin/gitlab-shell-authorized-keys-check git %u %k AuthorizedKeysCommandUser git ``` @@ -117,7 +117,7 @@ the database. The following instructions can be used to build OpenSSH 7.5: 1. First, download the package and install the required packages: - ``` + ```shell sudo su - cd /tmp curl --remote-name https://mirrors.evowise.com/pub/OpenBSD/OpenSSH/portable/openssh-7.5p1.tar.gz @@ -127,7 +127,7 @@ the database. The following instructions can be used to build OpenSSH 7.5: 1. Prepare the build by copying files to the right place: - ``` + ```shell mkdir -p /root/rpmbuild/{SOURCES,SPECS} cp ./openssh-7.5p1/contrib/redhat/openssh.spec /root/rpmbuild/SPECS/ cp openssh-7.5p1.tar.gz /root/rpmbuild/SOURCES/ @@ -136,7 +136,7 @@ the database. The following instructions can be used to build OpenSSH 7.5: 1. Next, set the spec settings properly: - ``` + ```shell sed -i -e "s/%define no_gnome_askpass 0/%define no_gnome_askpass 1/g" openssh.spec sed -i -e "s/%define no_x11_askpass 0/%define no_x11_askpass 1/g" openssh.spec sed -i -e "s/BuildPreReq/BuildRequires/g" openssh.spec @@ -144,19 +144,19 @@ the database. The following instructions can be used to build OpenSSH 7.5: 1. Build the RPMs: - ``` + ```shell rpmbuild -bb openssh.spec ``` 1. Ensure the RPMs were built: - ``` + ```shell ls -al /root/rpmbuild/RPMS/x86_64/ ``` You should see something as the following: - ``` + ```plaintext total 1324 drwxr-xr-x. 2 root root 4096 Jun 20 19:37 . drwxr-xr-x. 3 root root 19 Jun 20 19:37 .. @@ -170,7 +170,7 @@ the database. The following instructions can be used to build OpenSSH 7.5: with its own version, which may prevent users from logging in, so be sure that the file is backed up and restored after installation: - ``` + ```shell timestamp=$(date +%s) cp /etc/pam.d/sshd pam-ssh-conf-$timestamp rpm -Uvh /root/rpmbuild/RPMS/x86_64/*.rpm @@ -179,7 +179,7 @@ the database. The following instructions can be used to build OpenSSH 7.5: 1. Verify the installed version. In another window, attempt to login to the server: - ``` + ```shell ssh -v <your-centos-machine> ``` @@ -191,7 +191,7 @@ the database. The following instructions can be used to build OpenSSH 7.5: sure everything is working! If you need to downgrade, simple install the older package: - ``` + ```shell # Only run this if you run into a problem logging in yum downgrade openssh-server openssh openssh-clients ``` diff --git a/doc/administration/operations/puma.md b/doc/administration/operations/puma.md new file mode 100644 index 0000000000000000000000000000000000000000..2490cf1f0ae9603f4bcf7345ef90d5b1671c95dc --- /dev/null +++ b/doc/administration/operations/puma.md @@ -0,0 +1,46 @@ +# Switching to Puma + +## Puma + +GitLab plans to use [Puma](https://github.com/puma/puma) to replace +[Unicorn](https://bogomips.org/unicorn/). + +## Why switch to Puma? + +Puma has a multi-thread architecture which uses less memory than a multi-process +application server like Unicorn. + +Most Rails applications requests normally include a proportion of I/O wait time. +During I/O wait time MRI Ruby will release the GVL (Global VM Lock) to other threads. +Multi-threaded Puma can therefore still serve more requests than a single process. + +## Performance caveat when using Puma with Rugged + +For deployments where NFS is used to store Git repository, we allow GitLab to use +[Direct Git Access](../gitaly/#direct-git-access-in-gitlab-rails) to improve performance via usage of [Rugged](https://github.com/libgit2/rugged). + +Rugged usage is automatically enabled if Direct Git Access is present, unless it +is disabled by [feature flags](../../development/gitaly.md#legacy-rugged-code). + +MRI Ruby uses a GVL. This allows MRI Ruby to be multi-threaded, but running at +most on a single core. Since Rugged can use a thread for long periods of +time (due to intensive I/O operations of Git access), this can starve other threads +that might be processing requests. This is not a case for Unicorn or Puma running +in a single thread mode, as concurrently at most one request is being processed. + +We are actively working on removing Rugged usage. Even though performance without Rugged +is acceptable today, in some cases it might be still beneficial to run with it. + +Given the caveat of running Rugged with multi-threaded Puma, and acceptable +performance of Gitaly, we are disabling Rugged usage if Puma multi-threaded is +used (when Puma is configured to run with more than one thread). + +This default behavior may not be the optimal configuration in some situations. If Rugged +plays an important role in your deployment, we suggest you benchmark to find the +optimal configuration: + +- The safest option is to start with single-threaded Puma. When working with +Rugged, single-threaded Puma does work the same as Unicorn. + +- To force Rugged auto detect with multi-threaded Puma, you can use [feature +flags](../../development/gitaly.md#legacy-rugged-code). diff --git a/doc/administration/packages/container_registry.md b/doc/administration/packages/container_registry.md index e735d8dd97eda741d180df4dfde0c99f6c37d6fc..6ef1a3ec607030d67d4434ea987c105328f5a754 100644 --- a/doc/administration/packages/container_registry.md +++ b/doc/administration/packages/container_registry.md @@ -98,7 +98,7 @@ There are two ways you can configure the Registry's external domain. Either: for that domain. Since the container Registry requires a TLS certificate, in the end it all boils -down to how easy or pricey is to get a new one. +down to how easy or pricey it is to get a new one. Please take this into consideration before configuring the Container Registry for the first time. @@ -398,6 +398,9 @@ To configure the `s3` storage driver in Omnibus: 1. Save the file and [reconfigure GitLab](../restart_gitlab.md#omnibus-gitlab-reconfigure) for the changes to take effect. +NOTE: **Note:** +`your-s3-bucket` should only be the name of a bucket that exists, and can't include subdirectories. + **Installations from source** Configuring the storage driver is done in your registry config YML file created @@ -408,9 +411,9 @@ when you [deployed your docker registry](https://docs.docker.com/registry/deploy ```yml storage: s3: - accesskey: 'AKIAKIAKI' - secretkey: 'secret123' - bucket: 'gitlab-registry-bucket-AKIAKIAKI' + accesskey: 's3-access-key' + secretkey: 's3-secret-key-for-access-key' + bucket: 'your-s3-bucket' region: 'your-s3-region' regionendpoint: 'your-s3-regionendpoint' cache: @@ -419,6 +422,9 @@ storage: enabled: true ``` +NOTE: **Note:** +`your-s3-bucket` should only be the name of a bucket that exists, and can't include subdirectories. + ## Change the registry's internal port NOTE: **Note:** @@ -625,13 +631,36 @@ mounting the docker-daemon and setting `privileged = false` in the Runner's ```toml [runners.docker] - image = "ruby:2.1" + image = "ruby:2.6" privileged = false volumes = ["/var/run/docker.sock:/var/run/docker.sock", "/cache"] ``` Additional information about this: [issue 18239](https://gitlab.com/gitlab-org/gitlab-foss/issues/18239). +### `unauthorized: authentication required` when pushing large images + +Example error: + +```shell +docker push gitlab.example.com/myproject/docs:latest +The push refers to a repository [gitlab.example.com/myproject/docs] +630816f32edb: Preparing +530d5553aec8: Preparing +... +4b0bab9ff599: Waiting +d1c800db26c7: Waiting +42755cf4ee95: Waiting +unauthorized: authentication required +``` + +GitLab has a default token expiration of 5 minutes for the registry. When pushing +larger images, or images that take longer than 5 minutes to push, users may +encounter this error. + +Administrators can increase the token duration in **Admin area > Settings > +Container Registry > Authorization token duration (minutes)**. + ### AWS S3 with the GitLab registry error when pushing large images When using AWS S3 with the GitLab registry, an error may occur when pushing diff --git a/doc/administration/packages/index.md b/doc/administration/packages/index.md index 58dd8201c154972e1d96a98ae465e1bbecf7791f..432e72e03e3641f67e206c42daa345ced65d1102 100644 --- a/doc/administration/packages/index.md +++ b/doc/administration/packages/index.md @@ -8,6 +8,7 @@ The Packages feature allows GitLab to act as a repository for the following: | Software repository | Description | Available in GitLab version | | ------------------- | ----------- | --------------------------- | +| [NuGet Repository](../../user/packages/nuget_repository/index.md) | The GitLab NuGet Repository enables every project in GitLab to have its own space to store [NuGet](https://www.nuget.org/) packages. | 12.8+ | | [Conan Repository](../../user/packages/conan_repository/index.md) | The GitLab Conan Repository enables every project in GitLab to have its own space to store [Conan](https://conan.io/) packages. | 12.4+ | | [Maven Repository](../../user/packages/maven_repository/index.md) | The GitLab Maven Repository enables every project in GitLab to have its own space to store [Maven](https://maven.apache.org/) packages. | 11.3+ | | [NPM Registry](../../user/packages/npm_registry/index.md) | The GitLab NPM Registry enables every project in GitLab to have its own space to store [NPM](https://www.npmjs.com/) packages. | 11.7+ | diff --git a/doc/administration/pages/index.md b/doc/administration/pages/index.md index d1b58f2ee189dc77b59daf926d02ad0603783558..434cb2447c8a11e85db3c628729b8ca21dded93f 100644 --- a/doc/administration/pages/index.md +++ b/doc/administration/pages/index.md @@ -32,11 +32,11 @@ In the case of [custom domains](#custom-domains) (but not ports `80` and/or `443`. For that reason, there is some flexibility in the way which you can set it up: -1. Run the Pages daemon in the same server as GitLab, listening on a secondary IP. -1. Run the Pages daemon in a separate server. In that case, the +- Run the Pages daemon in the same server as GitLab, listening on a **secondary IP**. +- Run the Pages daemon in a [separate server](#running-gitlab-pages-on-a-separate-server). In that case, the [Pages path](#change-storage-path) must also be present in the server that the Pages daemon is installed, so you will have to share it via network. -1. Run the Pages daemon in the same server as GitLab, listening on the same IP +- Run the Pages daemon in the same server as GitLab, listening on the same IP but on different ports. In that case, you will have to proxy the traffic with a loadbalancer. If you choose that route note that you should use TCP load balancing for HTTPS. If you use TLS-termination (HTTPS-load balancing) the @@ -182,7 +182,7 @@ The [GitLab Pages README](https://gitlab.com/gitlab-org/gitlab-pages#caveats) ha In addition to the wildcard domains, you can also have the option to configure GitLab Pages to work with custom domains. Again, there are two options here: support custom domains with and without TLS certificates. The easiest setup is -that without TLS certificates. In either case, you'll need a secondary IP. If +that without TLS certificates. In either case, you'll need a **secondary IP**. If you have IPv6 as well as IPv4 addresses, you can use them both. ### Custom domains @@ -257,8 +257,8 @@ When adding a custom domain, users will be required to prove they own it by adding a GitLab-controlled verification code to the DNS records for that domain. If your userbase is private or otherwise trusted, you can disable the -verification requirement. Navigate to `Admin area ➔ Settings` and uncheck -**Require users to prove ownership of custom domains** in the Pages section. +verification requirement. Navigate to **Admin Area > Settings > Preferences** and +uncheck **Require users to prove ownership of custom domains** in the **Pages** section. This setting is enabled by default. ### Let's Encrypt integration @@ -307,6 +307,27 @@ Pages access control is disabled by default. To enable it: 1. [Reconfigure GitLab][reconfigure]. 1. Users can now configure it in their [projects' settings](../../user/project/pages/pages_access_control.md). +#### Disabling public access to all Pages websites + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/32095) in GitLab 12.7. + +You can enforce [Access Control](#access-control) for all GitLab Pages websites hosted +on your GitLab instance. By doing so, only logged-in users will have access to them. +This setting overrides Access Control set by users in individual projects. + +This can be useful to preserve information published with Pages websites to the users +of your instance only. +To do that: + +1. Navigate to your instance's **Admin Area > Settings > Preferences** and expand **Pages** settings. +1. Check the **Disable public access to Pages sites** checkbox. +1. Click **Save changes**. + +CAUTION: **Warning:** +This action will not make all currently public web-sites private until they redeployed. +This issue among others will be resolved by +[changing GitLab Pages configuration mechanism](https://gitlab.com/gitlab-org/gitlab-pages/issues/282). + ### Running behind a proxy Like the rest of GitLab, Pages can be used in those environments where external @@ -395,10 +416,26 @@ Omnibus GitLab 11.1. ## Set maximum pages size -The maximum size of the unpacked archive per project can be configured in the -Admin area under the Application settings in the **Maximum size of pages (MB)**. +You can configure the maximum size of the unpacked archive per project in +**Admin Area > Settings > Preferences > Pages**, in **Maximum size of pages (MB)**. The default is 100MB. +### Override maximum pages size per project or group **(PREMIUM ONLY)** + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/16610) in GitLab 12.7. + +To override the global maximum pages size for a specific project: + +1. Navigate to your project's **Settings > Pages** page. +1. Edit the **Maximum size of pages**. +1. Click **Save changes**. + +To override the global maximum pages size for a specific group: + +1. Navigate to your group's **Settings > General** page and expand **Pages**. +1. Edit the **Maximum size of pages**. +1. Click **Save changes**. + ## Running GitLab Pages on a separate server You can run the GitLab Pages daemon on a separate server in order to decrease the load on your main application server. diff --git a/doc/administration/pages/source.md b/doc/administration/pages/source.md index be8bba3c95b3dbd2e38eda9ff3e91acc458909f9..738eb87d53dec2e04941d5a0a9d44cce1a70fef4 100644 --- a/doc/administration/pages/source.md +++ b/doc/administration/pages/source.md @@ -433,8 +433,8 @@ are stored. ## Set maximum Pages size -The maximum size of the unpacked archive per project can be configured in the -Admin area under the Application settings in the **Maximum size of pages (MB)**. +The maximum size of the unpacked archive per project can be configured in +**Admin Area > Settings > Preferences > Pages**, in **Maximum size of pages (MB)**. The default is 100MB. ## Backup diff --git a/doc/administration/plugins.md b/doc/administration/plugins.md index df75d3a24bc57b47306bb2b83e2b0135f418b9a0..dbb733b9b19038bd0e700d9bc53fe457b7309719 100644 --- a/doc/administration/plugins.md +++ b/doc/administration/plugins.md @@ -1,115 +1,5 @@ -# GitLab Plugin system +--- +redirect_to: 'file_hooks.md' +--- -> Introduced in GitLab 10.6. - -With custom plugins, GitLab administrators can introduce custom integrations -without modifying GitLab's source code. - -NOTE: **Note:** -Instead of writing and supporting your own plugin you can make changes -directly to the GitLab source code and contribute back upstream. This way we can -ensure functionality is preserved across versions and covered by tests. - -NOTE: **Note:** -Plugins must be configured on the filesystem of the GitLab server. Only GitLab -server administrators will be able to complete these tasks. Explore -[system hooks] or [webhooks] as an option if you do not have filesystem access. - -A plugin will run on each event so it's up to you to filter events or projects -within a plugin code. You can have as many plugins as you want. Each plugin will -be triggered by GitLab asynchronously in case of an event. For a list of events -see the [system hooks] documentation. - -## Setup - -The plugins must be placed directly into the `plugins` directory, subdirectories -will be ignored. There is an -[`example` directory inside `plugins`](https://gitlab.com/gitlab-org/gitlab-foss/tree/master/plugins/examples) -where you can find some basic examples. - -Follow the steps below to set up a custom hook: - -1. On the GitLab server, navigate to the plugin directory. - For an installation from source the path is usually - `/home/git/gitlab/plugins/`. For Omnibus installs the path is - usually `/opt/gitlab/embedded/service/gitlab-rails/plugins`. - - For [highly available] configurations, your hook file should exist on each - application server. - -1. Inside the `plugins` directory, create a file with a name of your choice, - without spaces or special characters. -1. Make the hook file executable and make sure it's owned by the Git user. -1. Write the code to make the plugin function as expected. That can be - in any language, and ensure the 'shebang' at the top properly reflects the - language type. For example, if the script is in Ruby the shebang will - probably be `#!/usr/bin/env ruby`. -1. The data to the plugin will be provided as JSON on STDIN. It will be exactly - same as for [system hooks] - -That's it! Assuming the plugin code is properly implemented, the hook will fire -as appropriate. The plugins file list is updated for each event, there is no -need to restart GitLab to apply a new plugin. - -If a plugin executes with non-zero exit code or GitLab fails to execute it, a -message will be logged to: - -- `gitlab-rails/plugin.log` in an Omnibus installation. -- `log/plugin.log` in a source installation. - -## Creating plugins - -Below is an example that will only response on the event `project_create` and -will inform the admins from the GitLab instance that a new project has been created. - -```ruby -# By using the embedded ruby version we eliminate the possibility that our chosen language -# would be unavailable from -#!/opt/gitlab/embedded/bin/ruby -require 'json' -require 'mail' - -# The incoming variables are in JSON format so we need to parse it first. -ARGS = JSON.parse(STDIN.read) - -# We only want to trigger this plugin on the event project_create -return unless ARGS['event_name'] == 'project_create' - -# We will inform our admins of our gitlab instance that a new project is created -Mail.deliver do - from 'info@gitlab_instance.com' - to 'admin@gitlab_instance.com' - subject "new project " + ARGS['name'] - body ARGS['owner_name'] + 'created project ' + ARGS['name'] -end -``` - -## Validation - -Writing your own plugin can be tricky and it's easier if you can check it -without altering the system. A rake task is provided so that you can use it -in a staging environment to test your plugin before using it in production. -The rake task will use a sample data and execute each of plugin. The output -should be enough to determine if the system sees your plugin and if it was -executed without errors. - -```bash -# Omnibus installations -sudo gitlab-rake plugins:validate - -# Installations from source -cd /home/git/gitlab -bundle exec rake plugins:validate RAILS_ENV=production -``` - -Example of output: - -``` -Validating plugins from /plugins directory -* /home/git/gitlab/plugins/save_to_file.clj succeed (zero exit code) -* /home/git/gitlab/plugins/save_to_file.rb failure (non-zero exit code) -``` - -[system hooks]: ../system_hooks/system_hooks.md -[webhooks]: ../user/project/integrations/webhooks.md -[highly available]: ./high_availability/README.md +This document was moved to [File Hooks](file_hooks.md), after the Plugins feature was renamed to File Hooks. diff --git a/doc/administration/raketasks/uploads/migrate.md b/doc/administration/raketasks/uploads/migrate.md index 26c811ca54ded10ac1d047e9fbd414c665dcbd74..aef15e3f3885b042d0b11a73554010fa46956fb0 100644 --- a/doc/administration/raketasks/uploads/migrate.md +++ b/doc/administration/raketasks/uploads/migrate.md @@ -1,4 +1,4 @@ -# Uploads Migrate Rake Task +# Uploads Migrate Rake Tasks ## Migrate to Object Storage @@ -110,7 +110,15 @@ sudo -u git -H bundle exec rake "gitlab:uploads:migrate[FileUploader, MergeReque To migrate all uploads created by legacy uploaders, run: -```shell +**Omnibus Installation** + +```bash +gitlab-rake gitlab:uploads:legacy:migrate +``` + +**Source Installation** + +```bash bundle exec rake gitlab:uploads:legacy:migrate ``` diff --git a/doc/administration/repository_checks.md b/doc/administration/repository_checks.md index 6bf10441369bbe67bab768f50ce321b10996b480..decd708a85da9ac6f7bb55546dcb6932666475f0 100644 --- a/doc/administration/repository_checks.md +++ b/doc/administration/repository_checks.md @@ -36,10 +36,9 @@ in `repocheck.log`: - `/var/log/gitlab/gitlab-rails` for Omnibus installations - `/home/git/gitlab/log` for installations from source -If for some reason the periodic repository check caused a lot of false -alarms you can choose to clear *all* repository check states by -clicking "Clear all repository checks" on the **Settings** page of the -admin panel (`/admin/application_settings`). +If the periodic repository check causes false alarms, you can clear all repository check states by +navigating to **Admin Area > Settings > Repository** +(`/admin/application_settings/repository`) and clicking **Clear all repository checks**. --- [ce-3232]: https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/3232 "Auto git fsck" diff --git a/doc/administration/repository_storage_paths.md b/doc/administration/repository_storage_paths.md index d31b1d7fcd67a5dbaa563e732a7471850d558238..dfd0a618a735497520f91eb29160dc4b6d683def 100644 --- a/doc/administration/repository_storage_paths.md +++ b/doc/administration/repository_storage_paths.md @@ -110,7 +110,7 @@ Once you set the multiple storage paths, you can choose where new projects will be stored under **Admin Area > Settings > Repository > Repository storage > Storage nodes for new projects**. - + Beginning with GitLab 8.13.4, multiple paths can be chosen. New projects will be randomly placed on one of the selected paths. diff --git a/doc/administration/troubleshooting/debug.md b/doc/administration/troubleshooting/debug.md index 3007b711405aa99416d600e75028fabea44ade85..b754b954391c436497503ef73867c294c9d7bc1d 100644 --- a/doc/administration/troubleshooting/debug.md +++ b/doc/administration/troubleshooting/debug.md @@ -196,7 +196,7 @@ is a Unicorn worker that is spinning via `top`. Try to use the `gdb` techniques above. In addition, using `strace` may help isolate issues: ```shell -strace -tt -T -f -s 1024 -p <PID of unicorn worker> -o /tmp/unicorn.txt +strace -ttTfyyy -s 1024 -p <PID of unicorn worker> -o /tmp/unicorn.txt ``` If you cannot isolate which Unicorn worker is the issue, try to run `strace` @@ -204,7 +204,7 @@ on all the Unicorn workers to see where the `/internal/allowed` endpoint gets stuck: ```shell -ps auwx | grep unicorn | awk '{ print " -p " $2}' | xargs strace -tt -T -f -s 1024 -o /tmp/unicorn.txt +ps auwx | grep unicorn | awk '{ print " -p " $2}' | xargs strace -ttTfyyy -s 1024 -o /tmp/unicorn.txt ``` The output in `/tmp/unicorn.txt` may help diagnose the root cause. diff --git a/doc/administration/troubleshooting/elasticsearch.md b/doc/administration/troubleshooting/elasticsearch.md index 5846514c574e4bf8bafc558e72680d8a7be8cd6d..a582e07b141ddcc0bb480fa4fd4c525644f86ed4 100644 --- a/doc/administration/troubleshooting/elasticsearch.md +++ b/doc/administration/troubleshooting/elasticsearch.md @@ -106,7 +106,7 @@ graph TD; D2 --> |Yes| D4 D4 --> |No| D5 D4 --> |Yes| D6 - D{Is the error concerning<br>the beta indexer?} + D{Is the error concerning<br>the Go indexer?} D1[It would be best<br>to speak with an<br>Elasticsearch admin.] D2{Is the ICU development<br>package installed?} D3>This package is required.<br>Install the package<br>and retry.] @@ -245,12 +245,13 @@ much to "integrate" here. If the issue is: -- Not concerning the beta indexer, it is almost always an +- With the Go indexer, check if the ICU development package is installed. + This is a required package so make sure you install it. + Go indexer was a beta indexer which can be optionally turned on/off, but in 12.3 it reached stable status and is now the default. +- Not concerning the Go indexer, it is almost always an Elasticsearch-side issue. This means you should reach out to your Elasticsearch admin regarding the error(s) you are seeing. If you are unsure here, it never hurts to reach out to GitLab support. -- With the beta indexer, check if the ICU development package is installed. - This is a required package so make sure you install it. Beyond that, you will want to review the error. If it is: @@ -324,7 +325,7 @@ feel free to update that page with issues you encounter and solutions. ## Replication -Setting up Elasticsearch isn't too bad, but it can be a bit finnicky and time consuming. +Setting up Elasticsearch isn't too bad, but it can be a bit finicky and time consuming. The easiest method is to spin up a docker container with the required version and bind ports 9200/9300 so it can be used. diff --git a/doc/administration/troubleshooting/gitlab_rails_cheat_sheet.md b/doc/administration/troubleshooting/gitlab_rails_cheat_sheet.md index cb0b24ae026135d5c9aadb14fb494443d0efcaea..0ab8d629b61666b75d8134607e33722caaf4582a 100644 --- a/doc/administration/troubleshooting/gitlab_rails_cheat_sheet.md +++ b/doc/administration/troubleshooting/gitlab_rails_cheat_sheet.md @@ -743,6 +743,8 @@ Namespace.find_by_full_path("user/proj").namespace_statistics.update(shared_runn ### Remove artifacts more than a week old +The Latest version of these steps can be found in the [job artifacts documentation](../job_artifacts.md) + ```ruby ### SELECTING THE BUILDS TO CLEAR # For a single project: diff --git a/doc/administration/troubleshooting/postgresql.md b/doc/administration/troubleshooting/postgresql.md index 65c6952bf1c9ca01a72d47f0b2544506cdbb1027..ab302c919b2062b41d3c2d6fe36e403d4dbe13ab 100644 --- a/doc/administration/troubleshooting/postgresql.md +++ b/doc/administration/troubleshooting/postgresql.md @@ -41,7 +41,7 @@ This section is for links to information elsewhere in the GitLab documentation. - [Using Slony to update PostgreSQL](../../update/upgrading_postgresql_using_slony.md) - Uses replication to handle PostgreSQL upgrades - providing the schemas are the same. - - Reduces downtime to a short window for swinging over to the newer vewrsion. + - Reduces downtime to a short window for swinging over to the newer version. - Managing Omnibus PostgreSQL versions [from the development docs](https://docs.gitlab.com/omnibus/development/managing-postgresql-versions.html) diff --git a/doc/administration/troubleshooting/sidekiq.md b/doc/administration/troubleshooting/sidekiq.md index 41657368ea43702bccefce52a10479ed41f8c43a..91361dddf02320d80089705104c219996a57787c 100644 --- a/doc/administration/troubleshooting/sidekiq.md +++ b/doc/administration/troubleshooting/sidekiq.md @@ -174,7 +174,7 @@ the query details. ## Managing Sidekiq queues It is possible to use [Sidekiq API](https://github.com/mperham/sidekiq/wiki/API) -to perform a number of troubleshoting on Sidekiq. +to perform a number of troubleshooting on Sidekiq. These are the administrative commands and it should only be used if currently admin interface is not suitable due to scale of installation. diff --git a/doc/api/README.md b/doc/api/README.md index e756cd51997575c6119706eed1d12d288683e407..ef3b578f04ebf0a4bbcc6ca36b1175bf1d8db370 100644 --- a/doc/api/README.md +++ b/doc/api/README.md @@ -410,7 +410,7 @@ This method is controlled by the following parameters: In the example below, we list 50 [projects](projects.md) per page, ordered by `id` ascending. ```bash -curl --request PUT --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects?pagination=keyset&per_page=50&order_by=id&sort=asc" +curl --request GET --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects?pagination=keyset&per_page=50&order_by=id&sort=asc" ``` The response header includes a link to the next page. For example: @@ -426,7 +426,7 @@ Status: 200 OK The link to the next page contains an additional filter `id_after=42` which excludes records we have retrieved already. Note the type of filter depends on the `order_by` option used and we may have more than one additional filter. -The `Link` header is absent when the end of the collection has been reached and there are no additional records to retrieve. +When the end of the collection has been reached and there are no additional records to retrieve, the `Link` header is absent and the resulting array is empty. We recommend using only the given link to retrieve the next page instead of building your own URL. Apart from the headers shown, we don't expose additional pagination headers. diff --git a/doc/api/api_resources.md b/doc/api/api_resources.md index c2713f54c47a12deb33c3e70feeddd704c6d0f26..6eba9bf23bf2b0514f8b47efa8127e503c11e73b 100644 --- a/doc/api/api_resources.md +++ b/doc/api/api_resources.md @@ -29,6 +29,7 @@ The following API resources are available in the project context: | [Deployments](deployments.md) | `/projects/:id/deployments` | | [Discussions](discussions.md) (threaded comments) | `/projects/:id/issues/.../discussions`, `/projects/:id/snippets/.../discussions`, `/projects/:id/merge_requests/.../discussions`, `/projects/:id/commits/.../discussions` (also available for groups) | | [Environments](environments.md) | `/projects/:id/environments` | +| [Error Tracking](error_tracking.md) | `/projects/:id/error_tracking/settings` | | [Events](events.md) | `/projects/:id/events` (also available for users and standalone) | | [Issues](issues.md) | `/projects/:id/issues` (also available for groups and standalone) | | [Issues Statistics](issues_statistics.md) | `/projects/:id/issues_statistics` (also available for groups and standalone) | @@ -105,6 +106,7 @@ The following API resources are available outside of project and group contexts | Resource | Available endpoints | |:--------------------------------------------------|:------------------------------------------------------------------------| +| [Appearance](appearance.md) **(CORE ONLY)** | `/application/appearance` | | [Applications](applications.md) | `/applications` | | [Audit Events](audit_events.md) **(PREMIUM ONLY)** | `/audit_events` | | [Avatar](avatar.md) | `/avatar` | diff --git a/doc/api/appearance.md b/doc/api/appearance.md new file mode 100644 index 0000000000000000000000000000000000000000..e2c10fa2574c9459005fd2b0c4641216adca05de --- /dev/null +++ b/doc/api/appearance.md @@ -0,0 +1,80 @@ +# Appearance API **(CORE ONLY)** + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/16647) in GitLab 12.7. + +Appearance API allows you to maintain GitLab's appearance as if using the GitLab UI at +`/admin/appearance`. The API requires administrator privileges. + +## Get current appearance configuration + +List the current appearance configuration of the GitLab instance. + +``` +GET /application/appearance +``` + +```bash +curl --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/application/appearance +``` + +Example response: + +```json +{ + "title": "GitLab Test Instance", + "description": "gitlab-test.example.com", + "logo": "/uploads/-/system/appearance/logo/1/logo.png", + "header_logo": "/uploads/-/system/appearance/header_logo/1/header.png", + "favicon": "/uploads/-/system/appearance/favicon/1/favicon.png", + "new_project_guidelines": "Please read the FAQs for help.", + "header_message": "", + "footer_message": "", + "message_background_color": "#e75e40", + "message_font_color": "#ffffff", + "email_header_and_footer_enabled": false +} +``` + +## Change appearance configuration + +Use an API call to modify GitLab instance appearance configuration. + +``` +PUT /application/appearance +``` + +| Attribute | Type | Required | Description | +| --------------------------------- | ------- | -------- | ----------- | +| `title` | string | no | Instance title on the sign in / sign up page +| `description` | string | no | Markdown text shown on the sign in / sign up page +| `logo` | mixed | no | Instance image used on the sign in / sign up page +| `header_logo` | mixed | no | Instance image used for the main navigation bar +| `favicon` | mixed | no | Instance favicon in .ico/.png format +| `new_project_guidelines` | string | no | Markdown text shown on the new project page +| `header_message` | string | no | Message within the system header bar +| `footer_message` | string | no | Message within the system footer bar +| `message_background_color` | string | no | Background color for the system header / footer bar +| `message_font_color` | string | no | Font color for the system header / footer bar +| `email_header_and_footer_enabled` | boolean | no | Add header and footer to all outgoing emails if enabled + +```bash +curl --request PUT --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/application/appearance?email_header_and_footer_enabled=true&header_message=test +``` + +Example response: + +```json +{ + "title": "GitLab Test Instance", + "description": "gitlab-test.example.com", + "logo": "/uploads/-/system/appearance/logo/1/logo.png", + "header_logo": "/uploads/-/system/appearance/header_logo/1/header.png", + "favicon": "/uploads/-/system/appearance/favicon/1/favicon.png", + "new_project_guidelines": "Please read the FAQs for help.", + "header_message": "test", + "footer_message": "", + "message_background_color": "#e75e40", + "message_font_color": "#ffffff", + "email_header_and_footer_enabled": true +} +``` diff --git a/doc/api/deployments.md b/doc/api/deployments.md index 916c99d5f891466228555e0a182d861b4c37fcb3..3890a71f2834960a334cfbd01588b001d1ff00a6 100644 --- a/doc/api/deployments.md +++ b/doc/api/deployments.md @@ -15,6 +15,16 @@ GET /projects/:id/deployments | `sort` | string | no | Return deployments sorted in `asc` or `desc` order. Default is `asc` | | `updated_after` | datetime | no | Return deployments updated after the specified date | | `updated_before` | datetime | no | Return deployments updated before the specified date | +| `environment` | string | no | The name of the environment to filter deployments by | +| `status` | string | no | The status to filter deployments by | + +The status attribute can be one of the following values: + +- created +- running +- success +- failed +- canceled ```bash curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/1/deployments" @@ -349,3 +359,19 @@ Example of a response: "deployable": null } ``` + +## List of merge requests associated with a deployment + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/35739) in GitLab 12.7. + +This API retrieves the list of merge requests shipped with a given deployment: + +``` +GET /projects/:id/deployments/:deployment_id/merge_requests +``` + +It supports the same parameters as the [Merge Requests API](./merge_requests.md#list-merge-requests) and will return a response using the same format: + +```bash +curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/1/deployments/42" +``` diff --git a/doc/api/epics.md b/doc/api/epics.md index 531c75fd8c5abd4e21416715230f560e16b6976e..109c12c10528ec0fec592a659dfc9927095bb8b1 100644 --- a/doc/api/epics.md +++ b/doc/api/epics.md @@ -29,6 +29,14 @@ are paginated. Read more on [pagination](README.md#pagination). +CAUTION: **Deprecation** +> `reference` attribute in response is deprecated in favour of `references`. +> Introduced [GitLab 12.6](https://gitlab.com/gitlab-org/gitlab/merge_requests/20354) + +NOTE: **Note** +> `references.relative` is relative to the group that the epic is being requested. When epic is fetched from its origin group +> `relative` format would be the same as `short` format and when requested cross groups it is expected to be the same as `full` format. + ## List epics for a group Gets all epics of the requested group and its subgroups. @@ -45,6 +53,7 @@ GET /groups/:id/epics?state=opened | `id` | integer/string | yes | The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) owned by the authenticated user | | `author_id` | integer | no | Return epics created by the given user `id` | | `labels` | string | no | Return epics matching a comma separated list of labels names. Label names from the epic group or a parent group can be used | +| `with_labels_details` | Boolean | no | If `true`, response will return more details for each label in labels field: `:name`, `:color`, `:description`, `:description_html`, `:text_color`. Default is `false`. Introduced in [GitLab 12.7](https://gitlab.com/gitlab-org/gitlab/merge_requests/21413)| | `order_by` | string | no | Return epics ordered by `created_at` or `updated_at` fields. Default is `created_at` | | `sort` | string | no | Return epics sorted in `asc` or `desc` order. Default is `desc` | | `search` | string | no | Search epics against their `title` and `description` | @@ -73,6 +82,51 @@ Example response: "state": "opened", "web_url": "http://localhost:3001/groups/test/-/epics/4", "reference": "&4", + "references": { + "short": "&4", + "relative": "&4", + "full": "test&4" + }, + "author": { + "id": 10, + "name": "Lu Mayer", + "username": "kam", + "state": "active", + "avatar_url": "http://www.gravatar.com/avatar/018729e129a6f31c80a6327a30196823?s=80&d=identicon", + "web_url": "http://localhost:3001/kam" + }, + "start_date": null, + "start_date_is_fixed": false, + "start_date_fixed": null, + "start_date_from_milestones": null, //deprecated in favor of start_date_from_inherited_source + "start_date_from_inherited_source": null, + "end_date": "2018-07-31", //deprecated in favor of due_date + "due_date": "2018-07-31", + "due_date_is_fixed": false, + "due_date_fixed": null, + "due_date_from_milestones": "2018-07-31", //deprecated in favor of start_date_from_inherited_source + "due_date_from_inherited_source": "2018-07-31", + "created_at": "2018-07-17T13:36:22.770Z", + "updated_at": "2018-07-18T12:22:05.239Z", + "closed_at": "2018-08-18T12:22:05.239Z", + "labels": [], + "upvotes": 4, + "downvotes": 0 + }, + { + "id": 50, + "iid": 35, + "group_id": 17, + "title": "Accusamus iste et ullam ratione voluptatem omnis debitis dolor est.", + "description": "Molestias dolorem eos vitae expedita impedit necessitatibus quo voluptatum.", + "state": "opened", + "web_url": "http://localhost:3001/groups/test/sample/-/epics/4", + "reference": "&4", + "references": { + "short": "&4", + "relative": "sample&4", + "full": "test/sample&4" + }, "author": { "id": 10, "name": "Lu Mayer", @@ -131,6 +185,11 @@ Example response: "state": "opened", "web_url": "http://localhost:3001/groups/test/-/epics/5", "reference": "&5", + "references": { + "short": "&5", + "relative": "&5", + "full": "test&5" + }, "author":{ "id": 7, "name": "Pamella Huel", @@ -199,8 +258,13 @@ Example response: "title": "Epic", "description": "Epic description", "state": "opened", - "web_url": "http://localhost:3001/groups/test/-/epics/5", + "web_url": "http://localhost:3001/groups/test/-/epics/6", "reference": "&6", + "references": { + "short": "&6", + "relative": "&6", + "full": "test&6" + }, "author": { "name" : "Alexandra Bashirian", "avatar_url" : null, @@ -269,8 +333,13 @@ Example response: "title": "New Title", "description": "Epic description", "state": "opened", - "web_url": "http://localhost:3001/groups/test/-/epics/5", + "web_url": "http://localhost:3001/groups/test/-/epics/6", "reference": "&6", + "references": { + "short": "&6", + "relative": "&6", + "full": "test&6" + }, "author": { "name" : "Alexandra Bashirian", "avatar_url" : null, @@ -372,6 +441,13 @@ Example response: "avatar_url": "http://www.gravatar.com/avatar/a2f5c6fcef64c9c69cb8779cb292be1b?s=80&d=identicon", "web_url": "http://localhost:3001/arnita" }, + "web_url": "http://localhost:3001/groups/test/-/epics/5", + "reference": "&5", + "references": { + "short": "&5", + "relative": "&5", + "full": "test&5" + }, "start_date": null, "end_date": null, "created_at": "2018-01-21T06:21:13.165Z", diff --git a/doc/api/error_tracking.md b/doc/api/error_tracking.md new file mode 100644 index 0000000000000000000000000000000000000000..3c1fbb7dc7a932b4c257aca3fa97d86b40ae9f69 --- /dev/null +++ b/doc/api/error_tracking.md @@ -0,0 +1,32 @@ +# Error Tracking settings API + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/34940) in GitLab 12.7. + +## Error Tracking project settings + +The project settings API allows you to retrieve the Error Tracking settings for a project. Only for project maintainers. + +### Get Error Tracking settings + +``` +GET /projects/:id/error_tracking/settings +``` + +| Attribute | Type | Required | Description | +| --------- | ------- | -------- | --------------------- | +| `id` | integer | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user | + +```bash +curl --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/projects/1/error_tracking/settings +``` + +Example response: + +```json +{ + "active": true, + "project_name": "sample sentry project", + "sentry_external_url": "https://sentry.io/myawesomeproject/project", + "api_url": "https://sentry.io/api/0/projects/myawesomeproject/project" +} +``` diff --git a/doc/api/events.md b/doc/api/events.md index 1cd7047b867f3fb82cdc2590f6ef45c3eacd7f87..1dc0b054ee6d3798ac00786d132f354b8a214c81 100644 --- a/doc/api/events.md +++ b/doc/api/events.md @@ -66,12 +66,13 @@ Parameters: | `target_type` | string | no | Include only events of a particular [target type][target-types] | | `before` | date | no | Include only events created before a particular date. Please see [here for the supported format][date-formatting] | | `after` | date | no | Include only events created after a particular date. Please see [here for the supported format][date-formatting] | +| `scope` | string | no | Include all events across a user's projects. | | `sort` | string | no | Sort events in `asc` or `desc` order by `created_at`. Default is `desc` | Example request: ```bash -curl --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/events?target_type=issue&action=created&after=2017-01-31&before=2017-03-01 +curl --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/events?target_type=issue&action=created&after=2017-01-31&before=2017-03-01&scope=all ``` Example response: diff --git a/doc/api/geo_nodes.md b/doc/api/geo_nodes.md index e44d69f14192820e3f5faebe1f7f47160ca1a418..f54694ed15beccc3540f97eb6507c5165f8a180c 100644 --- a/doc/api/geo_nodes.md +++ b/doc/api/geo_nodes.md @@ -3,6 +3,61 @@ In order to interact with Geo node endpoints, you need to authenticate yourself as an admin. +## Create a new Geo node + +Creates a new Geo node. + +``` +POST /geo_nodes +``` + +```bash +curl --header "PRIVATE-TOKEN: <your_access_token>" https://primary.example.com/api/v4/geo_nodes \ + --request POST \ + -d "name=himynameissomething" \ + -d "url=https://another-node.example.com/" +``` + +| Attribute | Type | Required | Description | +| ----------------------------| ------- | -------- | -----------------------------------------------------------------| +| `primary` | boolean | no | Specifying whether this node will be primary. Defaults to false. | +| `enabled` | boolean | no | Flag indicating if the Geo node is enabled. Defaults to true. | +| `name` | string | yes | The unique identifier for the Geo node. Must match `geo_node_name` if it is set in `gitlab.rb`, otherwise it must match `external_url` | +| `url` | string | yes | The user-facing URL for the Geo node. | +| `internal_url` | string | no | The URL defined on the primary node that secondary nodes should use to contact it. Returns `url` if not set. | +| `files_max_capacity` | integer | no | Control the maximum concurrency of LFS/attachment backfill for this secondary node. Defaults to 10. | +| `repos_max_capacity` | integer | no | Control the maximum concurrency of repository backfill for this secondary node. Defaults to 25. | +| `verification_max_capacity` | integer | no | Control the maximum concurrency of repository verification for this node. Defaults to 100. | +| `container_repositories_max_capacity` | integer | no | Control the maximum concurrency of container repository sync for this node. Defaults to 10. | +| `sync_object_storage` | boolean | no | Flag indicating if the secondary Geo node will replicate blobs in Object Storage. Defaults to false. | + +Example response: + +```json +{ + "id": 3, + "name": "Test Node 1", + "url": "https://secondary.example.com/", + "internal_url": "https://secondary.example.com/", + "primary": false, + "enabled": true, + "current": false, + "files_max_capacity": 10, + "repos_max_capacity": 25, + "verification_max_capacity": 100, + "container_repositories_max_capacity": 10, + "sync_object_storage": false, + "clone_protocol": "http", + "web_edit_url": "https://primary.example.com/admin/geo/nodes/3/edit", + "web_geo_projects_url": "http://secondary.example.com/admin/geo/projects", + "_links": { + "self": "https://primary.example.com/api/v4/geo_nodes/3", + "status": "https://primary.example.com/api/v4/geo_nodes/3/status", + "repair": "https://primary.example.com/api/v4/geo_nodes/3/repair" + } +} +``` + ## Retrieve configuration about all Geo nodes ``` diff --git a/doc/api/graphql/reference/gitlab_schema.graphql b/doc/api/graphql/reference/gitlab_schema.graphql index 4673356cf9dc5b2b0dffa7d63a0e456ede569155..23ffae3b097b5690e802f2564c5babe8d8daf323 100644 --- a/doc/api/graphql/reference/gitlab_schema.graphql +++ b/doc/api/graphql/reference/gitlab_schema.graphql @@ -38,6 +38,9 @@ type AddAwardEmojiPayload { errors: [String!]! } +""" +An emoji awarded by a user. +""" type AwardEmoji { """ The emoji description @@ -71,17 +74,44 @@ type AwardEmoji { } type Blob implements Entry { + """ + Flat path of the entry + """ flatPath: String! + + """ + ID of the entry + """ id: ID! + + """ + LFS ID of the blob + """ lfsOid: String + + """ + Name of the entry + """ name: String! + + """ + Path of the entry + """ path: String! """ - Last commit sha for entry + Last commit sha for the entry """ sha: String! + + """ + Type of tree entry + """ type: EntryType! + + """ + Web URL of the blob + """ webUrl: String } @@ -502,7 +532,13 @@ type CreateSnippetPayload { snippet: Snippet } -type Design implements Noteable { +""" +A single design +""" +type Design implements DesignFields & Noteable { + """ + The diff refs for this design + """ diffRefs: DiffRefs! """ @@ -531,13 +567,33 @@ type Design implements Noteable { ): DiscussionConnection! """ - The change that happened to the design at this version + How this design was changed in the current version """ event: DesignVersionEvent! + + """ + The filename of the design + """ filename: String! + + """ + The full path to the design file + """ fullPath: String! + + """ + The ID of this design + """ id: ID! + + """ + The URL of the image + """ image: String! + + """ + The issue the design belongs to + """ issue: Issue! """ @@ -569,6 +625,10 @@ type Design implements Noteable { The total count of user-created notes for this design """ notesCount: Int! + + """ + The project the design belongs to + """ project: Project! """ @@ -597,9 +657,12 @@ type Design implements Noteable { ): DesignVersionConnection! } +""" +A collection of designs. +""" type DesignCollection { """ - All designs for this collection + All designs for the design collection """ designs( """ @@ -638,11 +701,19 @@ type DesignCollection { """ last: Int ): DesignConnection! + + """ + Issue associated with the design collection + """ issue: Issue! + + """ + Project associated with the design collection + """ project: Project! """ - All versions related to all designs ordered newest first + All versions related to all designs, ordered newest first """ versions( """ @@ -702,6 +773,53 @@ type DesignEdge { node: Design } +interface DesignFields { + """ + The diff refs for this design + """ + diffRefs: DiffRefs! + + """ + How this design was changed in the current version + """ + event: DesignVersionEvent! + + """ + The filename of the design + """ + filename: String! + + """ + The full path to the design file + """ + fullPath: String! + + """ + The ID of this design + """ + id: ID! + + """ + The URL of the image + """ + image: String! + + """ + The issue the design belongs to + """ + issue: Issue! + + """ + The total count of user-created notes for this design + """ + notesCount: Int! + + """ + The project the design belongs to + """ + project: Project! +} + """ Autogenerated input type of DesignManagementDelete """ @@ -799,7 +917,7 @@ type DesignManagementUploadPayload { type DesignVersion { """ - All designs that were changed in this version + All designs that were changed in the version """ designs( """ @@ -822,7 +940,15 @@ type DesignVersion { """ last: Int ): DesignConnection! + + """ + ID of the design version + """ id: ID! + + """ + SHA of the design version + """ sha: ID! } @@ -862,7 +988,7 @@ type DesignVersionEdge { } """ -Mutation event of a Design within a Version +Mutation event of a design within a version """ enum DesignVersionEvent { """ @@ -957,13 +1083,44 @@ type DestroySnippetPayload { } type DetailedStatus { + """ + Path of the details for the pipeline status + """ detailsPath: String! + + """ + Favicon of the pipeline status + """ favicon: String! + + """ + Group of the pipeline status + """ group: String! + + """ + Indicates if the pipeline status has further details + """ hasDetails: Boolean! + + """ + Icon of the pipeline status + """ icon: String! + + """ + Label of the pipeline status + """ label: String! + + """ + Text of the pipeline status + """ text: String! + + """ + Tooltip associated with the pipeline status + """ tooltip: String! } @@ -1215,15 +1372,34 @@ type DiscussionEdge { } interface Entry { + """ + Flat path of the entry + """ flatPath: String! + + """ + ID of the entry + """ id: ID! + + """ + Name of the entry + """ name: String! + + """ + Path of the entry + """ path: String! """ - Last commit sha for entry + Last commit sha for the entry """ sha: String! + + """ + Type of tree entry + """ type: EntryType! } @@ -1236,6 +1412,59 @@ enum EntryType { tree } +""" +Describes where code is deployed for a project +""" +type Environment { + """ + ID of the environment + """ + id: ID! + + """ + Human-readable name of the environment + """ + name: String! +} + +""" +The connection type for Environment. +""" +type EnvironmentConnection { + """ + A list of edges. + """ + edges: [EnvironmentEdge] + + """ + A list of nodes. + """ + nodes: [Environment] + + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! +} + +""" +An edge in a connection. +""" +type EnvironmentEdge { + """ + A cursor for use in pagination. + """ + cursor: String! + + """ + The item at the end of the edge. + """ + node: Environment +} + +""" +Represents an epic. +""" type Epic implements Noteable { """ Author of the epic @@ -1547,7 +1776,7 @@ type Epic implements Noteable { state: EpicState! """ - Boolean flag for whether the currently logged in user is subscribed to this epic + Indicates the currently logged in user is subscribed to the epic """ subscribed: Boolean! @@ -1594,6 +1823,9 @@ type EpicConnection { pageInfo: PageInfo! } +""" +Counts of descendent epics. +""" type EpicDescendantCount { """ Number of closed sub-epics @@ -1631,6 +1863,9 @@ type EpicEdge { node: Epic } +""" +Relationship between an epic and an issue +""" type EpicIssue implements Noteable { """ Assignees of the issue @@ -1693,7 +1928,7 @@ type EpicIssue implements Noteable { designCollection: DesignCollection """ - Deprecated. Use `design_collection`. + Deprecated. Use `design_collection` """ designs: DesignCollection @deprecated(reason: "use design_collection") @@ -1863,7 +2098,7 @@ type EpicIssue implements Noteable { state: IssueState! """ - Boolean flag for whether the currently logged in user is subscribed to this issue + Indicates the currently logged in user is subscribed to the issue """ subscribed: Boolean! @@ -1968,42 +2203,42 @@ Check permissions for the current user on an epic """ type EpicPermissions { """ - Whether or not a user can perform `admin_epic` on this resource + Indicates the user can perform `admin_epic` on this resource """ adminEpic: Boolean! """ - Whether or not a user can perform `award_emoji` on this resource + Indicates the user can perform `award_emoji` on this resource """ awardEmoji: Boolean! """ - Whether or not a user can perform `create_epic` on this resource + Indicates the user can perform `create_epic` on this resource """ createEpic: Boolean! """ - Whether or not a user can perform `create_note` on this resource + Indicates the user can perform `create_note` on this resource """ createNote: Boolean! """ - Whether or not a user can perform `destroy_epic` on this resource + Indicates the user can perform `destroy_epic` on this resource """ destroyEpic: Boolean! """ - Whether or not a user can perform `read_epic` on this resource + Indicates the user can perform `read_epic` on this resource """ readEpic: Boolean! """ - Whether or not a user can perform `read_epic_iid` on this resource + Indicates the user can perform `read_epic_iid` on this resource """ readEpicIid: Boolean! """ - Whether or not a user can perform `update_epic` on this resource + Indicates the user can perform `update_epic` on this resource """ updateEpic: Boolean! } @@ -2079,7 +2314,7 @@ enum EpicSort { } """ -State of a GitLab epic +State of an epic. """ enum EpicState { all @@ -2088,20 +2323,23 @@ enum EpicState { } """ -State event of a GitLab Epic +State event of an epic """ enum EpicStateEvent { """ - Close the Epic + Close the epic """ CLOSE """ - Reopen the Epic + Reopen the epic """ REOPEN } +""" +A node of an epic tree. +""" input EpicTreeNodeFieldsInputType { """ The id of the epic_issue or issue that the actual epic or issue is switched with @@ -2154,6 +2392,38 @@ type EpicTreeReorderPayload { errors: [String!]! } +type GrafanaIntegration { + """ + Timestamp of the issue's creation + """ + createdAt: Time! + + """ + Indicates whether Grafana integration is enabled + """ + enabled: Boolean! + + """ + Url for the Grafana host for the Grafana integration + """ + grafanaUrl: String! + + """ + Internal ID of the Grafana integration + """ + id: ID! + + """ + API token for the Grafana integration + """ + token: String! + + """ + Timestamp of the issue's last activity + """ + updatedAt: Time! +} + type Group { """ Avatar URL of the group @@ -2324,6 +2594,11 @@ type Group { """ lfsEnabled: Boolean + """ + Indicates if a group is disabled from getting mentioned + """ + mentionsDisabled: Boolean + """ Name of the namespace """ @@ -2432,7 +2707,7 @@ type Group { type GroupPermissions { """ - Whether or not a user can perform `read_group` on this resource + Indicates the user can perform `read_group` on this resource """ readGroup: Boolean! } @@ -2508,7 +2783,7 @@ type Issue implements Noteable { designCollection: DesignCollection """ - Deprecated. Use `design_collection`. + Deprecated. Use `design_collection` """ designs: DesignCollection @deprecated(reason: "use design_collection") @@ -2663,7 +2938,7 @@ type Issue implements Noteable { state: IssueState! """ - Boolean flag for whether the currently logged in user is subscribed to this issue + Indicates the currently logged in user is subscribed to the issue """ subscribed: Boolean! @@ -2768,42 +3043,42 @@ Check permissions for the current user on a issue """ type IssuePermissions { """ - Whether or not a user can perform `admin_issue` on this resource + Indicates the user can perform `admin_issue` on this resource """ adminIssue: Boolean! """ - Whether or not a user can perform `create_design` on this resource + Indicates the user can perform `create_design` on this resource """ createDesign: Boolean! """ - Whether or not a user can perform `create_note` on this resource + Indicates the user can perform `create_note` on this resource """ createNote: Boolean! """ - Whether or not a user can perform `destroy_design` on this resource + Indicates the user can perform `destroy_design` on this resource """ destroyDesign: Boolean! """ - Whether or not a user can perform `read_design` on this resource + Indicates the user can perform `read_design` on this resource """ readDesign: Boolean! """ - Whether or not a user can perform `read_issue` on this resource + Indicates the user can perform `read_issue` on this resource """ readIssue: Boolean! """ - Whether or not a user can perform `reopen_issue` on this resource + Indicates the user can perform `reopen_issue` on this resource """ reopenIssue: Boolean! """ - Whether or not a user can perform `update_issue` on this resource + Indicates the user can perform `update_issue` on this resource """ updateIssue: Boolean! } @@ -3561,42 +3836,42 @@ Check permissions for the current user on a merge request """ type MergeRequestPermissions { """ - Whether or not a user can perform `admin_merge_request` on this resource + Indicates the user can perform `admin_merge_request` on this resource """ adminMergeRequest: Boolean! """ - Whether or not a user can perform `cherry_pick_on_current_merge_request` on this resource + Indicates the user can perform `cherry_pick_on_current_merge_request` on this resource """ cherryPickOnCurrentMergeRequest: Boolean! """ - Whether or not a user can perform `create_note` on this resource + Indicates the user can perform `create_note` on this resource """ createNote: Boolean! """ - Whether or not a user can perform `push_to_source_branch` on this resource + Indicates the user can perform `push_to_source_branch` on this resource """ pushToSourceBranch: Boolean! """ - Whether or not a user can perform `read_merge_request` on this resource + Indicates the user can perform `read_merge_request` on this resource """ readMergeRequest: Boolean! """ - Whether or not a user can perform `remove_source_branch` on this resource + Indicates the user can perform `remove_source_branch` on this resource """ removeSourceBranch: Boolean! """ - Whether or not a user can perform `revert_on_current_merge_request` on this resource + Indicates the user can perform `revert_on_current_merge_request` on this resource """ revertOnCurrentMergeRequest: Boolean! """ - Whether or not a user can perform `update_merge_request` on this resource + Indicates the user can perform `update_merge_request` on this resource """ updateMergeRequest: Boolean! } @@ -4209,27 +4484,27 @@ type NoteEdge { type NotePermissions { """ - Whether or not a user can perform `admin_note` on this resource + Indicates the user can perform `admin_note` on this resource """ adminNote: Boolean! """ - Whether or not a user can perform `award_emoji` on this resource + Indicates the user can perform `award_emoji` on this resource """ awardEmoji: Boolean! """ - Whether or not a user can perform `create_note` on this resource + Indicates the user can perform `create_note` on this resource """ createNote: Boolean! """ - Whether or not a user can perform `read_note` on this resource + Indicates the user can perform `read_note` on this resource """ readNote: Boolean! """ - Whether or not a user can perform `resolve_note` on this resource + Indicates the user can perform `resolve_note` on this resource """ resolveNote: Boolean! } @@ -4312,26 +4587,70 @@ type PageInfo { } type Pipeline { + """ + Base SHA of the source branch + """ beforeSha: String + + """ + Timestamp of the pipeline's commit + """ committedAt: Time """ Coverage percentage """ coverage: Float + + """ + Timestamp of the pipeline's creation + """ createdAt: Time! + + """ + Detailed status of the pipeline + """ detailedStatus: DetailedStatus! """ Duration of the pipeline in seconds """ duration: Int + + """ + Timestamp of the pipeline's completion + """ finishedAt: Time + + """ + ID of the pipeline + """ id: ID! + + """ + Internal ID of the pipeline + """ iid: String! + + """ + SHA of the pipeline's commit + """ sha: String! + + """ + Timestamp when the pipeline was started + """ startedAt: Time + + """ + Status of the pipeline (CREATED, WAITING_FOR_RESOURCE, PREPARING, PENDING, + RUNNING, FAILED, SUCCESS, CANCELED, SKIPPED, MANUAL, SCHEDULED) + """ status: PipelineStatusEnum! + + """ + Timestamp of the pipeline's last activity + """ updatedAt: Time! """ @@ -4377,17 +4696,17 @@ type PipelineEdge { type PipelinePermissions { """ - Whether or not a user can perform `admin_pipeline` on this resource + Indicates the user can perform `admin_pipeline` on this resource """ adminPipeline: Boolean! """ - Whether or not a user can perform `destroy_pipeline` on this resource + Indicates the user can perform `destroy_pipeline` on this resource """ destroyPipeline: Boolean! """ - Whether or not a user can perform `update_pipeline` on this resource + Indicates the user can perform `update_pipeline` on this resource """ updatePipeline: Boolean! } @@ -4403,14 +4722,20 @@ enum PipelineStatusEnum { SCHEDULED SKIPPED SUCCESS + WAITING_FOR_RESOURCE } type Project { """ - Archived status of the project + Indicates the archived status of the project """ archived: Boolean + """ + Indicates if issues referenced by merge requests and commits within the default branch are closed automatically + """ + autocloseReferencedIssues: Boolean + """ URL to avatar image file of the project """ @@ -4436,6 +4761,41 @@ type Project { """ descriptionHtml: String + """ + Environments of the project + """ + environments( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: String + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: String + + """ + Returns the first _n_ elements from the list. + """ + first: Int + + """ + Returns the last _n_ elements from the list. + """ + last: Int + + """ + Name of the environment + """ + name: String + + """ + Search query + """ + search: String + ): EnvironmentConnection + """ Number of times the project has been forked """ @@ -4446,6 +4806,11 @@ type Project { """ fullPath: ID! + """ + Grafana integration details for the project + """ + grafanaIntegration: GrafanaIntegration + """ Group of the project """ @@ -4879,6 +5244,11 @@ type Project { """ statistics: ProjectStatistics + """ + The commit message used to apply merge request suggestions + """ + suggestionCommitMessage: String + """ List of project tags """ @@ -4942,207 +5312,207 @@ type ProjectEdge { type ProjectPermissions { """ - Whether or not a user can perform `admin_operations` on this resource + Indicates the user can perform `admin_operations` on this resource """ adminOperations: Boolean! """ - Whether or not a user can perform `admin_project` on this resource + Indicates the user can perform `admin_project` on this resource """ adminProject: Boolean! """ - Whether or not a user can perform `admin_remote_mirror` on this resource + Indicates the user can perform `admin_remote_mirror` on this resource """ adminRemoteMirror: Boolean! """ - Whether or not a user can perform `admin_wiki` on this resource + Indicates the user can perform `admin_wiki` on this resource """ adminWiki: Boolean! """ - Whether or not a user can perform `archive_project` on this resource + Indicates the user can perform `archive_project` on this resource """ archiveProject: Boolean! """ - Whether or not a user can perform `change_namespace` on this resource + Indicates the user can perform `change_namespace` on this resource """ changeNamespace: Boolean! """ - Whether or not a user can perform `change_visibility_level` on this resource + Indicates the user can perform `change_visibility_level` on this resource """ changeVisibilityLevel: Boolean! """ - Whether or not a user can perform `create_deployment` on this resource + Indicates the user can perform `create_deployment` on this resource """ createDeployment: Boolean! """ - Whether or not a user can perform `create_design` on this resource + Indicates the user can perform `create_design` on this resource """ createDesign: Boolean! """ - Whether or not a user can perform `create_issue` on this resource + Indicates the user can perform `create_issue` on this resource """ createIssue: Boolean! """ - Whether or not a user can perform `create_label` on this resource + Indicates the user can perform `create_label` on this resource """ createLabel: Boolean! """ - Whether or not a user can perform `create_merge_request_from` on this resource + Indicates the user can perform `create_merge_request_from` on this resource """ createMergeRequestFrom: Boolean! """ - Whether or not a user can perform `create_merge_request_in` on this resource + Indicates the user can perform `create_merge_request_in` on this resource """ createMergeRequestIn: Boolean! """ - Whether or not a user can perform `create_pages` on this resource + Indicates the user can perform `create_pages` on this resource """ createPages: Boolean! """ - Whether or not a user can perform `create_pipeline` on this resource + Indicates the user can perform `create_pipeline` on this resource """ createPipeline: Boolean! """ - Whether or not a user can perform `create_pipeline_schedule` on this resource + Indicates the user can perform `create_pipeline_schedule` on this resource """ createPipelineSchedule: Boolean! """ - Whether or not a user can perform `create_snippet` on this resource + Indicates the user can perform `create_snippet` on this resource """ createSnippet: Boolean! """ - Whether or not a user can perform `create_wiki` on this resource + Indicates the user can perform `create_wiki` on this resource """ createWiki: Boolean! """ - Whether or not a user can perform `destroy_design` on this resource + Indicates the user can perform `destroy_design` on this resource """ destroyDesign: Boolean! """ - Whether or not a user can perform `destroy_pages` on this resource + Indicates the user can perform `destroy_pages` on this resource """ destroyPages: Boolean! """ - Whether or not a user can perform `destroy_wiki` on this resource + Indicates the user can perform `destroy_wiki` on this resource """ destroyWiki: Boolean! """ - Whether or not a user can perform `download_code` on this resource + Indicates the user can perform `download_code` on this resource """ downloadCode: Boolean! """ - Whether or not a user can perform `download_wiki_code` on this resource + Indicates the user can perform `download_wiki_code` on this resource """ downloadWikiCode: Boolean! """ - Whether or not a user can perform `fork_project` on this resource + Indicates the user can perform `fork_project` on this resource """ forkProject: Boolean! """ - Whether or not a user can perform `push_code` on this resource + Indicates the user can perform `push_code` on this resource """ pushCode: Boolean! """ - Whether or not a user can perform `push_to_delete_protected_branch` on this resource + Indicates the user can perform `push_to_delete_protected_branch` on this resource """ pushToDeleteProtectedBranch: Boolean! """ - Whether or not a user can perform `read_commit_status` on this resource + Indicates the user can perform `read_commit_status` on this resource """ readCommitStatus: Boolean! """ - Whether or not a user can perform `read_cycle_analytics` on this resource + Indicates the user can perform `read_cycle_analytics` on this resource """ readCycleAnalytics: Boolean! """ - Whether or not a user can perform `read_design` on this resource + Indicates the user can perform `read_design` on this resource """ readDesign: Boolean! """ - Whether or not a user can perform `read_pages_content` on this resource + Indicates the user can perform `read_pages_content` on this resource """ readPagesContent: Boolean! """ - Whether or not a user can perform `read_project` on this resource + Indicates the user can perform `read_project` on this resource """ readProject: Boolean! """ - Whether or not a user can perform `read_project_member` on this resource + Indicates the user can perform `read_project_member` on this resource """ readProjectMember: Boolean! """ - Whether or not a user can perform `read_wiki` on this resource + Indicates the user can perform `read_wiki` on this resource """ readWiki: Boolean! """ - Whether or not a user can perform `remove_fork_project` on this resource + Indicates the user can perform `remove_fork_project` on this resource """ removeForkProject: Boolean! """ - Whether or not a user can perform `remove_pages` on this resource + Indicates the user can perform `remove_pages` on this resource """ removePages: Boolean! """ - Whether or not a user can perform `remove_project` on this resource + Indicates the user can perform `remove_project` on this resource """ removeProject: Boolean! """ - Whether or not a user can perform `rename_project` on this resource + Indicates the user can perform `rename_project` on this resource """ renameProject: Boolean! """ - Whether or not a user can perform `request_access` on this resource + Indicates the user can perform `request_access` on this resource """ requestAccess: Boolean! """ - Whether or not a user can perform `update_pages` on this resource + Indicates the user can perform `update_pages` on this resource """ updatePages: Boolean! """ - Whether or not a user can perform `update_wiki` on this resource + Indicates the user can perform `update_wiki` on this resource """ updateWiki: Boolean! """ - Whether or not a user can perform `upload_file` on this resource + Indicates the user can perform `upload_file` on this resource """ uploadFile: Boolean! } @@ -5436,6 +5806,16 @@ type SentryDetailedError { """ frequency: [SentryErrorFrequency!]! + """ + GitLab commit SHA attributed to the Error based on the release version + """ + gitlabCommit: String + + """ + Path to the GitLab page for the GitLab commit attributed to the error + """ + gitlabCommitPath: String + """ ID (global ID) of the error """ @@ -5706,48 +6086,75 @@ type SnippetEdge { type SnippetPermissions { """ - Whether or not a user can perform `admin_snippet` on this resource + Indicates the user can perform `admin_snippet` on this resource """ adminSnippet: Boolean! """ - Whether or not a user can perform `award_emoji` on this resource + Indicates the user can perform `award_emoji` on this resource """ awardEmoji: Boolean! """ - Whether or not a user can perform `create_note` on this resource + Indicates the user can perform `create_note` on this resource """ createNote: Boolean! """ - Whether or not a user can perform `read_snippet` on this resource + Indicates the user can perform `read_snippet` on this resource """ readSnippet: Boolean! """ - Whether or not a user can perform `report_snippet` on this resource + Indicates the user can perform `report_snippet` on this resource """ reportSnippet: Boolean! """ - Whether or not a user can perform `update_snippet` on this resource + Indicates the user can perform `update_snippet` on this resource """ updateSnippet: Boolean! } type Submodule implements Entry { + """ + Flat path of the entry + """ flatPath: String! + + """ + ID of the entry + """ id: ID! + + """ + Name of the entry + """ name: String! + + """ + Path of the entry + """ path: String! """ - Last commit sha for entry + Last commit sha for the entry """ sha: String! + + """ + Tree URL for the sub-module + """ treeUrl: String + + """ + Type of tree entry + """ type: EntryType! + + """ + Web URL for the sub-module + """ webUrl: String } @@ -6130,12 +6537,15 @@ type ToggleAwardEmojiPayload { errors: [String!]! """ - True when the emoji was awarded, false when it was removed + Indicates the status of the emoji. True if the toggle awarded the emoji, and false if the toggle removed the emoji. """ toggledOn: Boolean! } type Tree { + """ + Blobs of the tree + """ blobs( """ Returns the elements in the list that come after the specified cursor. @@ -6162,6 +6572,10 @@ type Tree { Last commit for the tree """ lastCommit: Commit + + """ + Sub-modules of the tree + """ submodules( """ Returns the elements in the list that come after the specified cursor. @@ -6183,6 +6597,10 @@ type Tree { """ last: Int ): SubmoduleConnection! + + """ + Trees of the tree + """ trees( """ Returns the elements in the list that come after the specified cursor. @@ -6210,16 +6628,39 @@ type Tree { Represents a directory """ type TreeEntry implements Entry { + """ + Flat path of the entry + """ flatPath: String! + + """ + ID of the entry + """ id: ID! + + """ + Name of the entry + """ name: String! + + """ + Path of the entry + """ path: String! """ - Last commit sha for entry + Last commit sha for the entry """ sha: String! + + """ + Type of tree entry + """ type: EntryType! + + """ + Web URL for the tree entry (directory) + """ webUrl: String } @@ -6609,7 +7050,7 @@ type UserEdge { type UserPermissions { """ - Whether or not a user can perform `create_snippet` on this resource + Indicates the user can perform `create_snippet` on this resource """ createSnippet: Boolean! } diff --git a/doc/api/graphql/reference/gitlab_schema.json b/doc/api/graphql/reference/gitlab_schema.json index 398ae52c130c7739963c7787a30f0964cfde4255..6239a398c7e4ed0ce4793e82223258750f4c81e3 100644 --- a/doc/api/graphql/reference/gitlab_schema.json +++ b/doc/api/graphql/reference/gitlab_schema.json @@ -310,7 +310,21 @@ "fields": [ { "name": "archived", - "description": "Archived status of the project", + "description": "Indicates the archived status of the project", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "autocloseReferencedIssues", + "description": "Indicates if issues referenced by merge requests and commits within the default branch are closed automatically", "args": [ ], @@ -392,6 +406,79 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "environments", + "description": "Environments of the project", + "args": [ + { + "name": "name", + "description": "Name of the environment", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "search", + "description": "Search query", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "after", + "description": "Returns the elements in the list that come after the specified cursor.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "before", + "description": "Returns the elements in the list that come before the specified cursor.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "first", + "description": "Returns the first _n_ elements from the list.", + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "last", + "description": "Returns the last _n_ elements from the list.", + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null + } + ], + "type": { + "kind": "OBJECT", + "name": "EnvironmentConnection", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "forksCount", "description": "Number of times the project has been forked", @@ -428,6 +515,20 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "grafanaIntegration", + "description": "Grafana integration details for the project", + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "GrafanaIntegration", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "group", "description": "Group of the project", @@ -1497,6 +1598,20 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "suggestionCommitMessage", + "description": "The commit message used to apply merge request suggestions", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "tagList", "description": "List of project tags", @@ -1586,7 +1701,7 @@ "fields": [ { "name": "adminOperations", - "description": "Whether or not a user can perform `admin_operations` on this resource", + "description": "Indicates the user can perform `admin_operations` on this resource", "args": [ ], @@ -1604,7 +1719,7 @@ }, { "name": "adminProject", - "description": "Whether or not a user can perform `admin_project` on this resource", + "description": "Indicates the user can perform `admin_project` on this resource", "args": [ ], @@ -1622,7 +1737,7 @@ }, { "name": "adminRemoteMirror", - "description": "Whether or not a user can perform `admin_remote_mirror` on this resource", + "description": "Indicates the user can perform `admin_remote_mirror` on this resource", "args": [ ], @@ -1640,7 +1755,7 @@ }, { "name": "adminWiki", - "description": "Whether or not a user can perform `admin_wiki` on this resource", + "description": "Indicates the user can perform `admin_wiki` on this resource", "args": [ ], @@ -1658,7 +1773,7 @@ }, { "name": "archiveProject", - "description": "Whether or not a user can perform `archive_project` on this resource", + "description": "Indicates the user can perform `archive_project` on this resource", "args": [ ], @@ -1676,7 +1791,7 @@ }, { "name": "changeNamespace", - "description": "Whether or not a user can perform `change_namespace` on this resource", + "description": "Indicates the user can perform `change_namespace` on this resource", "args": [ ], @@ -1694,7 +1809,7 @@ }, { "name": "changeVisibilityLevel", - "description": "Whether or not a user can perform `change_visibility_level` on this resource", + "description": "Indicates the user can perform `change_visibility_level` on this resource", "args": [ ], @@ -1712,7 +1827,7 @@ }, { "name": "createDeployment", - "description": "Whether or not a user can perform `create_deployment` on this resource", + "description": "Indicates the user can perform `create_deployment` on this resource", "args": [ ], @@ -1730,7 +1845,7 @@ }, { "name": "createDesign", - "description": "Whether or not a user can perform `create_design` on this resource", + "description": "Indicates the user can perform `create_design` on this resource", "args": [ ], @@ -1748,7 +1863,7 @@ }, { "name": "createIssue", - "description": "Whether or not a user can perform `create_issue` on this resource", + "description": "Indicates the user can perform `create_issue` on this resource", "args": [ ], @@ -1766,7 +1881,7 @@ }, { "name": "createLabel", - "description": "Whether or not a user can perform `create_label` on this resource", + "description": "Indicates the user can perform `create_label` on this resource", "args": [ ], @@ -1784,7 +1899,7 @@ }, { "name": "createMergeRequestFrom", - "description": "Whether or not a user can perform `create_merge_request_from` on this resource", + "description": "Indicates the user can perform `create_merge_request_from` on this resource", "args": [ ], @@ -1802,7 +1917,7 @@ }, { "name": "createMergeRequestIn", - "description": "Whether or not a user can perform `create_merge_request_in` on this resource", + "description": "Indicates the user can perform `create_merge_request_in` on this resource", "args": [ ], @@ -1820,7 +1935,7 @@ }, { "name": "createPages", - "description": "Whether or not a user can perform `create_pages` on this resource", + "description": "Indicates the user can perform `create_pages` on this resource", "args": [ ], @@ -1838,7 +1953,7 @@ }, { "name": "createPipeline", - "description": "Whether or not a user can perform `create_pipeline` on this resource", + "description": "Indicates the user can perform `create_pipeline` on this resource", "args": [ ], @@ -1856,7 +1971,7 @@ }, { "name": "createPipelineSchedule", - "description": "Whether or not a user can perform `create_pipeline_schedule` on this resource", + "description": "Indicates the user can perform `create_pipeline_schedule` on this resource", "args": [ ], @@ -1874,7 +1989,7 @@ }, { "name": "createSnippet", - "description": "Whether or not a user can perform `create_snippet` on this resource", + "description": "Indicates the user can perform `create_snippet` on this resource", "args": [ ], @@ -1892,7 +2007,7 @@ }, { "name": "createWiki", - "description": "Whether or not a user can perform `create_wiki` on this resource", + "description": "Indicates the user can perform `create_wiki` on this resource", "args": [ ], @@ -1910,7 +2025,7 @@ }, { "name": "destroyDesign", - "description": "Whether or not a user can perform `destroy_design` on this resource", + "description": "Indicates the user can perform `destroy_design` on this resource", "args": [ ], @@ -1928,7 +2043,7 @@ }, { "name": "destroyPages", - "description": "Whether or not a user can perform `destroy_pages` on this resource", + "description": "Indicates the user can perform `destroy_pages` on this resource", "args": [ ], @@ -1946,7 +2061,7 @@ }, { "name": "destroyWiki", - "description": "Whether or not a user can perform `destroy_wiki` on this resource", + "description": "Indicates the user can perform `destroy_wiki` on this resource", "args": [ ], @@ -1964,7 +2079,7 @@ }, { "name": "downloadCode", - "description": "Whether or not a user can perform `download_code` on this resource", + "description": "Indicates the user can perform `download_code` on this resource", "args": [ ], @@ -1982,7 +2097,7 @@ }, { "name": "downloadWikiCode", - "description": "Whether or not a user can perform `download_wiki_code` on this resource", + "description": "Indicates the user can perform `download_wiki_code` on this resource", "args": [ ], @@ -2000,7 +2115,7 @@ }, { "name": "forkProject", - "description": "Whether or not a user can perform `fork_project` on this resource", + "description": "Indicates the user can perform `fork_project` on this resource", "args": [ ], @@ -2018,7 +2133,7 @@ }, { "name": "pushCode", - "description": "Whether or not a user can perform `push_code` on this resource", + "description": "Indicates the user can perform `push_code` on this resource", "args": [ ], @@ -2036,7 +2151,7 @@ }, { "name": "pushToDeleteProtectedBranch", - "description": "Whether or not a user can perform `push_to_delete_protected_branch` on this resource", + "description": "Indicates the user can perform `push_to_delete_protected_branch` on this resource", "args": [ ], @@ -2054,7 +2169,7 @@ }, { "name": "readCommitStatus", - "description": "Whether or not a user can perform `read_commit_status` on this resource", + "description": "Indicates the user can perform `read_commit_status` on this resource", "args": [ ], @@ -2072,7 +2187,7 @@ }, { "name": "readCycleAnalytics", - "description": "Whether or not a user can perform `read_cycle_analytics` on this resource", + "description": "Indicates the user can perform `read_cycle_analytics` on this resource", "args": [ ], @@ -2090,7 +2205,7 @@ }, { "name": "readDesign", - "description": "Whether or not a user can perform `read_design` on this resource", + "description": "Indicates the user can perform `read_design` on this resource", "args": [ ], @@ -2108,7 +2223,7 @@ }, { "name": "readPagesContent", - "description": "Whether or not a user can perform `read_pages_content` on this resource", + "description": "Indicates the user can perform `read_pages_content` on this resource", "args": [ ], @@ -2126,7 +2241,7 @@ }, { "name": "readProject", - "description": "Whether or not a user can perform `read_project` on this resource", + "description": "Indicates the user can perform `read_project` on this resource", "args": [ ], @@ -2144,7 +2259,7 @@ }, { "name": "readProjectMember", - "description": "Whether or not a user can perform `read_project_member` on this resource", + "description": "Indicates the user can perform `read_project_member` on this resource", "args": [ ], @@ -2162,7 +2277,7 @@ }, { "name": "readWiki", - "description": "Whether or not a user can perform `read_wiki` on this resource", + "description": "Indicates the user can perform `read_wiki` on this resource", "args": [ ], @@ -2180,7 +2295,7 @@ }, { "name": "removeForkProject", - "description": "Whether or not a user can perform `remove_fork_project` on this resource", + "description": "Indicates the user can perform `remove_fork_project` on this resource", "args": [ ], @@ -2198,7 +2313,7 @@ }, { "name": "removePages", - "description": "Whether or not a user can perform `remove_pages` on this resource", + "description": "Indicates the user can perform `remove_pages` on this resource", "args": [ ], @@ -2216,7 +2331,7 @@ }, { "name": "removeProject", - "description": "Whether or not a user can perform `remove_project` on this resource", + "description": "Indicates the user can perform `remove_project` on this resource", "args": [ ], @@ -2234,7 +2349,7 @@ }, { "name": "renameProject", - "description": "Whether or not a user can perform `rename_project` on this resource", + "description": "Indicates the user can perform `rename_project` on this resource", "args": [ ], @@ -2252,7 +2367,7 @@ }, { "name": "requestAccess", - "description": "Whether or not a user can perform `request_access` on this resource", + "description": "Indicates the user can perform `request_access` on this resource", "args": [ ], @@ -2270,7 +2385,7 @@ }, { "name": "updatePages", - "description": "Whether or not a user can perform `update_pages` on this resource", + "description": "Indicates the user can perform `update_pages` on this resource", "args": [ ], @@ -2288,7 +2403,7 @@ }, { "name": "updateWiki", - "description": "Whether or not a user can perform `update_wiki` on this resource", + "description": "Indicates the user can perform `update_wiki` on this resource", "args": [ ], @@ -2306,7 +2421,7 @@ }, { "name": "uploadFile", - "description": "Whether or not a user can perform `upload_file` on this resource", + "description": "Indicates the user can perform `upload_file` on this resource", "args": [ ], @@ -3345,6 +3460,20 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "mentionsDisabled", + "description": "Indicates if a group is disabled from getting mentioned", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "name", "description": "Name of the namespace", @@ -3640,7 +3769,7 @@ "fields": [ { "name": "readGroup", - "description": "Whether or not a user can perform `read_group` on this resource", + "description": "Indicates the user can perform `read_group` on this resource", "args": [ ], @@ -3667,7 +3796,7 @@ { "kind": "OBJECT", "name": "Epic", - "description": null, + "description": "Represents an epic.", "fields": [ { "name": "author", @@ -4484,7 +4613,7 @@ }, { "name": "subscribed", - "description": "Boolean flag for whether the currently logged in user is subscribed to this epic", + "description": "Indicates the currently logged in user is subscribed to the epic", "args": [ ], @@ -5128,7 +5257,7 @@ "fields": [ { "name": "adminNote", - "description": "Whether or not a user can perform `admin_note` on this resource", + "description": "Indicates the user can perform `admin_note` on this resource", "args": [ ], @@ -5146,7 +5275,7 @@ }, { "name": "awardEmoji", - "description": "Whether or not a user can perform `award_emoji` on this resource", + "description": "Indicates the user can perform `award_emoji` on this resource", "args": [ ], @@ -5164,7 +5293,7 @@ }, { "name": "createNote", - "description": "Whether or not a user can perform `create_note` on this resource", + "description": "Indicates the user can perform `create_note` on this resource", "args": [ ], @@ -5182,7 +5311,7 @@ }, { "name": "readNote", - "description": "Whether or not a user can perform `read_note` on this resource", + "description": "Indicates the user can perform `read_note` on this resource", "args": [ ], @@ -5200,7 +5329,7 @@ }, { "name": "resolveNote", - "description": "Whether or not a user can perform `resolve_note` on this resource", + "description": "Indicates the user can perform `resolve_note` on this resource", "args": [ ], @@ -5590,7 +5719,7 @@ "fields": [ { "name": "createSnippet", - "description": "Whether or not a user can perform `create_snippet` on this resource", + "description": "Indicates the user can perform `create_snippet` on this resource", "args": [ ], @@ -6732,7 +6861,7 @@ "fields": [ { "name": "adminSnippet", - "description": "Whether or not a user can perform `admin_snippet` on this resource", + "description": "Indicates the user can perform `admin_snippet` on this resource", "args": [ ], @@ -6750,7 +6879,7 @@ }, { "name": "awardEmoji", - "description": "Whether or not a user can perform `award_emoji` on this resource", + "description": "Indicates the user can perform `award_emoji` on this resource", "args": [ ], @@ -6768,7 +6897,7 @@ }, { "name": "createNote", - "description": "Whether or not a user can perform `create_note` on this resource", + "description": "Indicates the user can perform `create_note` on this resource", "args": [ ], @@ -6786,7 +6915,7 @@ }, { "name": "readSnippet", - "description": "Whether or not a user can perform `read_snippet` on this resource", + "description": "Indicates the user can perform `read_snippet` on this resource", "args": [ ], @@ -6804,7 +6933,7 @@ }, { "name": "reportSnippet", - "description": "Whether or not a user can perform `report_snippet` on this resource", + "description": "Indicates the user can perform `report_snippet` on this resource", "args": [ ], @@ -6822,7 +6951,7 @@ }, { "name": "updateSnippet", - "description": "Whether or not a user can perform `update_snippet` on this resource", + "description": "Indicates the user can perform `update_snippet` on this resource", "args": [ ], @@ -7203,7 +7332,7 @@ "fields": [ { "name": "adminEpic", - "description": "Whether or not a user can perform `admin_epic` on this resource", + "description": "Indicates the user can perform `admin_epic` on this resource", "args": [ ], @@ -7221,7 +7350,7 @@ }, { "name": "awardEmoji", - "description": "Whether or not a user can perform `award_emoji` on this resource", + "description": "Indicates the user can perform `award_emoji` on this resource", "args": [ ], @@ -7239,7 +7368,7 @@ }, { "name": "createEpic", - "description": "Whether or not a user can perform `create_epic` on this resource", + "description": "Indicates the user can perform `create_epic` on this resource", "args": [ ], @@ -7257,7 +7386,7 @@ }, { "name": "createNote", - "description": "Whether or not a user can perform `create_note` on this resource", + "description": "Indicates the user can perform `create_note` on this resource", "args": [ ], @@ -7275,7 +7404,7 @@ }, { "name": "destroyEpic", - "description": "Whether or not a user can perform `destroy_epic` on this resource", + "description": "Indicates the user can perform `destroy_epic` on this resource", "args": [ ], @@ -7293,7 +7422,7 @@ }, { "name": "readEpic", - "description": "Whether or not a user can perform `read_epic` on this resource", + "description": "Indicates the user can perform `read_epic` on this resource", "args": [ ], @@ -7311,7 +7440,7 @@ }, { "name": "readEpicIid", - "description": "Whether or not a user can perform `read_epic_iid` on this resource", + "description": "Indicates the user can perform `read_epic_iid` on this resource", "args": [ ], @@ -7329,7 +7458,7 @@ }, { "name": "updateEpic", - "description": "Whether or not a user can perform `update_epic` on this resource", + "description": "Indicates the user can perform `update_epic` on this resource", "args": [ ], @@ -7356,7 +7485,7 @@ { "kind": "ENUM", "name": "EpicState", - "description": "State of a GitLab epic", + "description": "State of an epic.", "fields": null, "inputFields": null, "interfaces": null, @@ -7981,7 +8110,7 @@ { "kind": "OBJECT", "name": "EpicIssue", - "description": null, + "description": "Relationship between an epic and an issue", "fields": [ { "name": "assignees", @@ -8148,7 +8277,7 @@ }, { "name": "designs", - "description": "Deprecated. Use `design_collection`.", + "description": "Deprecated. Use `design_collection`", "args": [ ], @@ -8583,7 +8712,7 @@ }, { "name": "subscribed", - "description": "Boolean flag for whether the currently logged in user is subscribed to this issue", + "description": "Indicates the currently logged in user is subscribed to the issue", "args": [ ], @@ -8826,7 +8955,7 @@ "fields": [ { "name": "adminIssue", - "description": "Whether or not a user can perform `admin_issue` on this resource", + "description": "Indicates the user can perform `admin_issue` on this resource", "args": [ ], @@ -8844,7 +8973,7 @@ }, { "name": "createDesign", - "description": "Whether or not a user can perform `create_design` on this resource", + "description": "Indicates the user can perform `create_design` on this resource", "args": [ ], @@ -8862,7 +8991,7 @@ }, { "name": "createNote", - "description": "Whether or not a user can perform `create_note` on this resource", + "description": "Indicates the user can perform `create_note` on this resource", "args": [ ], @@ -8880,7 +9009,7 @@ }, { "name": "destroyDesign", - "description": "Whether or not a user can perform `destroy_design` on this resource", + "description": "Indicates the user can perform `destroy_design` on this resource", "args": [ ], @@ -8898,7 +9027,7 @@ }, { "name": "readDesign", - "description": "Whether or not a user can perform `read_design` on this resource", + "description": "Indicates the user can perform `read_design` on this resource", "args": [ ], @@ -8916,7 +9045,7 @@ }, { "name": "readIssue", - "description": "Whether or not a user can perform `read_issue` on this resource", + "description": "Indicates the user can perform `read_issue` on this resource", "args": [ ], @@ -8934,7 +9063,7 @@ }, { "name": "reopenIssue", - "description": "Whether or not a user can perform `reopen_issue` on this resource", + "description": "Indicates the user can perform `reopen_issue` on this resource", "args": [ ], @@ -8952,7 +9081,7 @@ }, { "name": "updateIssue", - "description": "Whether or not a user can perform `update_issue` on this resource", + "description": "Indicates the user can perform `update_issue` on this resource", "args": [ ], @@ -9202,11 +9331,11 @@ { "kind": "OBJECT", "name": "DesignCollection", - "description": null, + "description": "A collection of designs.", "fields": [ { "name": "designs", - "description": "All designs for this collection", + "description": "All designs for the design collection", "args": [ { "name": "ids", @@ -9309,7 +9438,7 @@ }, { "name": "issue", - "description": null, + "description": "Issue associated with the design collection", "args": [ ], @@ -9327,7 +9456,7 @@ }, { "name": "project", - "description": null, + "description": "Project associated with the design collection", "args": [ ], @@ -9345,7 +9474,7 @@ }, { "name": "versions", - "description": "All versions related to all designs ordered newest first", + "description": "All versions related to all designs, ordered newest first", "args": [ { "name": "after", @@ -9578,7 +9707,7 @@ }, { "name": "designs", - "description": "Deprecated. Use `design_collection`.", + "description": "Deprecated. Use `design_collection`", "args": [ ], @@ -9967,7 +10096,7 @@ }, { "name": "subscribed", - "description": "Boolean flag for whether the currently logged in user is subscribed to this issue", + "description": "Indicates the currently logged in user is subscribed to the issue", "args": [ ], @@ -10318,11 +10447,11 @@ { "kind": "OBJECT", "name": "Design", - "description": null, + "description": "A single design", "fields": [ { "name": "diffRefs", - "description": null, + "description": "The diff refs for this design", "args": [ ], @@ -10397,7 +10526,7 @@ }, { "name": "event", - "description": "The change that happened to the design at this version", + "description": "How this design was changed in the current version", "args": [ ], @@ -10415,7 +10544,7 @@ }, { "name": "filename", - "description": null, + "description": "The filename of the design", "args": [ ], @@ -10433,7 +10562,7 @@ }, { "name": "fullPath", - "description": null, + "description": "The full path to the design file", "args": [ ], @@ -10451,7 +10580,7 @@ }, { "name": "id", - "description": null, + "description": "The ID of this design", "args": [ ], @@ -10469,7 +10598,7 @@ }, { "name": "image", - "description": null, + "description": "The URL of the image", "args": [ ], @@ -10487,7 +10616,7 @@ }, { "name": "issue", - "description": null, + "description": "The issue the design belongs to", "args": [ ], @@ -10580,7 +10709,7 @@ }, { "name": "project", - "description": null, + "description": "The project the design belongs to", "args": [ ], @@ -10660,63 +10789,69 @@ "kind": "INTERFACE", "name": "Noteable", "ofType": null + }, + { + "kind": "INTERFACE", + "name": "DesignFields", + "ofType": null } ], "enumValues": null, "possibleTypes": null }, { - "kind": "ENUM", - "name": "DesignVersionEvent", - "description": "Mutation event of a Design within a Version", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "NONE", - "description": "No change", - "isDeprecated": false, - "deprecationReason": null - }, + "kind": "INTERFACE", + "name": "DesignFields", + "description": null, + "fields": [ { - "name": "CREATION", - "description": "A creation event", + "name": "diffRefs", + "description": "The diff refs for this design", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "DiffRefs", + "ofType": null + } + }, "isDeprecated": false, "deprecationReason": null }, { - "name": "MODIFICATION", - "description": "A modification event", + "name": "event", + "description": "How this design was changed in the current version", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "DesignVersionEvent", + "ofType": null + } + }, "isDeprecated": false, "deprecationReason": null }, { - "name": "DELETION", - "description": "A deletion event", - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "DesignVersionConnection", - "description": "The connection type for DesignVersion.", - "fields": [ - { - "name": "edges", - "description": "A list of edges.", + "name": "filename", + "description": "The filename of the design", "args": [ ], "type": { - "kind": "LIST", + "kind": "NON_NULL", "name": null, "ofType": { - "kind": "OBJECT", - "name": "DesignVersionEdge", + "kind": "SCALAR", + "name": "String", "ofType": null } }, @@ -10724,17 +10859,17 @@ "deprecationReason": null }, { - "name": "nodes", - "description": "A list of nodes.", + "name": "fullPath", + "description": "The full path to the design file", "args": [ ], "type": { - "kind": "LIST", + "kind": "NON_NULL", "name": null, "ofType": { - "kind": "OBJECT", - "name": "DesignVersion", + "kind": "SCALAR", + "name": "String", "ofType": null } }, @@ -10742,8 +10877,8 @@ "deprecationReason": null }, { - "name": "pageInfo", - "description": "Information to aid in pagination.", + "name": "id", + "description": "The ID of this design", "args": [ ], @@ -10751,30 +10886,17 @@ "kind": "NON_NULL", "name": null, "ofType": { - "kind": "OBJECT", - "name": "PageInfo", + "kind": "SCALAR", + "name": "ID", "ofType": null } }, "isDeprecated": false, "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [ - - ], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "DesignVersionEdge", - "description": "An edge in a connection.", - "fields": [ + }, { - "name": "cursor", - "description": "A cursor for use in pagination.", + "name": "image", + "description": "The URL of the image", "args": [ ], @@ -10791,24 +10913,215 @@ "deprecationReason": null }, { - "name": "node", - "description": "The item at the end of the edge.", + "name": "issue", + "description": "The issue the design belongs to", "args": [ ], "type": { - "kind": "OBJECT", - "name": "DesignVersion", - "ofType": null + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Issue", + "ofType": null + } }, "isDeprecated": false, "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [ + }, + { + "name": "notesCount", + "description": "The total count of user-created notes for this design", + "args": [ - ], + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "project", + "description": "The project the design belongs to", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Project", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": [ + { + "kind": "OBJECT", + "name": "Design", + "ofType": null + } + ] + }, + { + "kind": "ENUM", + "name": "DesignVersionEvent", + "description": "Mutation event of a design within a version", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "NONE", + "description": "No change", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "CREATION", + "description": "A creation event", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "MODIFICATION", + "description": "A modification event", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "DELETION", + "description": "A deletion event", + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "DesignVersionConnection", + "description": "The connection type for DesignVersion.", + "fields": [ + { + "name": "edges", + "description": "A list of edges.", + "args": [ + + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "DesignVersionEdge", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "nodes", + "description": "A list of nodes.", + "args": [ + + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "DesignVersion", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "pageInfo", + "description": "Information to aid in pagination.", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "PageInfo", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "DesignVersionEdge", + "description": "An edge in a connection.", + "fields": [ + { + "name": "cursor", + "description": "A cursor for use in pagination.", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "node", + "description": "The item at the end of the edge.", + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "DesignVersion", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], "enumValues": null, "possibleTypes": null }, @@ -10819,7 +11132,7 @@ "fields": [ { "name": "designs", - "description": "All designs that were changed in this version", + "description": "All designs that were changed in the version", "args": [ { "name": "after", @@ -10876,7 +11189,7 @@ }, { "name": "id", - "description": null, + "description": "ID of the design version", "args": [ ], @@ -10894,7 +11207,7 @@ }, { "name": "sha", - "description": null, + "description": "SHA of the design version", "args": [ ], @@ -10921,7 +11234,7 @@ { "kind": "OBJECT", "name": "EpicDescendantCount", - "description": null, + "description": "Counts of descendent epics.", "fields": [ { "name": "closedEpics", @@ -11428,7 +11741,7 @@ "fields": [ { "name": "blobs", - "description": null, + "description": "Blobs of the tree", "args": [ { "name": "after", @@ -11499,7 +11812,7 @@ }, { "name": "submodules", - "description": null, + "description": "Sub-modules of the tree", "args": [ { "name": "after", @@ -11556,7 +11869,7 @@ }, { "name": "trees", - "description": null, + "description": "Trees of the tree", "args": [ { "name": "after", @@ -12029,7 +12342,7 @@ "fields": [ { "name": "beforeSha", - "description": null, + "description": "Base SHA of the source branch", "args": [ ], @@ -12043,7 +12356,7 @@ }, { "name": "committedAt", - "description": null, + "description": "Timestamp of the pipeline's commit", "args": [ ], @@ -12071,7 +12384,7 @@ }, { "name": "createdAt", - "description": null, + "description": "Timestamp of the pipeline's creation", "args": [ ], @@ -12089,7 +12402,7 @@ }, { "name": "detailedStatus", - "description": null, + "description": "Detailed status of the pipeline", "args": [ ], @@ -12121,7 +12434,7 @@ }, { "name": "finishedAt", - "description": null, + "description": "Timestamp of the pipeline's completion", "args": [ ], @@ -12135,7 +12448,7 @@ }, { "name": "id", - "description": null, + "description": "ID of the pipeline", "args": [ ], @@ -12153,7 +12466,7 @@ }, { "name": "iid", - "description": null, + "description": "Internal ID of the pipeline", "args": [ ], @@ -12171,7 +12484,7 @@ }, { "name": "sha", - "description": null, + "description": "SHA of the pipeline's commit", "args": [ ], @@ -12189,7 +12502,7 @@ }, { "name": "startedAt", - "description": null, + "description": "Timestamp when the pipeline was started", "args": [ ], @@ -12203,7 +12516,7 @@ }, { "name": "status", - "description": null, + "description": "Status of the pipeline (CREATED, WAITING_FOR_RESOURCE, PREPARING, PENDING, RUNNING, FAILED, SUCCESS, CANCELED, SKIPPED, MANUAL, SCHEDULED)", "args": [ ], @@ -12221,7 +12534,7 @@ }, { "name": "updatedAt", - "description": null, + "description": "Timestamp of the pipeline's last activity", "args": [ ], @@ -12270,7 +12583,7 @@ "fields": [ { "name": "adminPipeline", - "description": "Whether or not a user can perform `admin_pipeline` on this resource", + "description": "Indicates the user can perform `admin_pipeline` on this resource", "args": [ ], @@ -12288,7 +12601,7 @@ }, { "name": "destroyPipeline", - "description": "Whether or not a user can perform `destroy_pipeline` on this resource", + "description": "Indicates the user can perform `destroy_pipeline` on this resource", "args": [ ], @@ -12306,7 +12619,7 @@ }, { "name": "updatePipeline", - "description": "Whether or not a user can perform `update_pipeline` on this resource", + "description": "Indicates the user can perform `update_pipeline` on this resource", "args": [ ], @@ -12344,6 +12657,12 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "WAITING_FOR_RESOURCE", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "PREPARING", "description": null, @@ -12408,7 +12727,7 @@ "fields": [ { "name": "detailsPath", - "description": null, + "description": "Path of the details for the pipeline status", "args": [ ], @@ -12426,7 +12745,7 @@ }, { "name": "favicon", - "description": null, + "description": "Favicon of the pipeline status", "args": [ ], @@ -12444,7 +12763,7 @@ }, { "name": "group", - "description": null, + "description": "Group of the pipeline status", "args": [ ], @@ -12462,7 +12781,7 @@ }, { "name": "hasDetails", - "description": null, + "description": "Indicates if the pipeline status has further details", "args": [ ], @@ -12480,7 +12799,7 @@ }, { "name": "icon", - "description": null, + "description": "Icon of the pipeline status", "args": [ ], @@ -12498,7 +12817,7 @@ }, { "name": "label", - "description": null, + "description": "Label of the pipeline status", "args": [ ], @@ -12516,7 +12835,7 @@ }, { "name": "text", - "description": null, + "description": "Text of the pipeline status", "args": [ ], @@ -12534,7 +12853,7 @@ }, { "name": "tooltip", - "description": null, + "description": "Tooltip associated with the pipeline status", "args": [ ], @@ -12687,7 +13006,7 @@ "fields": [ { "name": "flatPath", - "description": null, + "description": "Flat path of the entry", "args": [ ], @@ -12705,7 +13024,7 @@ }, { "name": "id", - "description": null, + "description": "ID of the entry", "args": [ ], @@ -12723,7 +13042,7 @@ }, { "name": "name", - "description": null, + "description": "Name of the entry", "args": [ ], @@ -12741,7 +13060,7 @@ }, { "name": "path", - "description": null, + "description": "Path of the entry", "args": [ ], @@ -12759,7 +13078,7 @@ }, { "name": "sha", - "description": "Last commit sha for entry", + "description": "Last commit sha for the entry", "args": [ ], @@ -12777,7 +13096,7 @@ }, { "name": "type", - "description": null, + "description": "Type of tree entry", "args": [ ], @@ -12795,7 +13114,7 @@ }, { "name": "webUrl", - "description": null, + "description": "Web URL for the tree entry (directory)", "args": [ ], @@ -12826,7 +13145,7 @@ "fields": [ { "name": "flatPath", - "description": null, + "description": "Flat path of the entry", "args": [ ], @@ -12844,7 +13163,7 @@ }, { "name": "id", - "description": null, + "description": "ID of the entry", "args": [ ], @@ -12862,7 +13181,7 @@ }, { "name": "name", - "description": null, + "description": "Name of the entry", "args": [ ], @@ -12880,7 +13199,7 @@ }, { "name": "path", - "description": null, + "description": "Path of the entry", "args": [ ], @@ -12898,7 +13217,7 @@ }, { "name": "sha", - "description": "Last commit sha for entry", + "description": "Last commit sha for the entry", "args": [ ], @@ -12916,7 +13235,7 @@ }, { "name": "type", - "description": null, + "description": "Type of tree entry", "args": [ ], @@ -13102,7 +13421,7 @@ "fields": [ { "name": "flatPath", - "description": null, + "description": "Flat path of the entry", "args": [ ], @@ -13120,7 +13439,7 @@ }, { "name": "id", - "description": null, + "description": "ID of the entry", "args": [ ], @@ -13138,7 +13457,7 @@ }, { "name": "name", - "description": null, + "description": "Name of the entry", "args": [ ], @@ -13156,7 +13475,7 @@ }, { "name": "path", - "description": null, + "description": "Path of the entry", "args": [ ], @@ -13174,7 +13493,7 @@ }, { "name": "sha", - "description": "Last commit sha for entry", + "description": "Last commit sha for the entry", "args": [ ], @@ -13192,7 +13511,7 @@ }, { "name": "treeUrl", - "description": null, + "description": "Tree URL for the sub-module", "args": [ ], @@ -13206,7 +13525,7 @@ }, { "name": "type", - "description": null, + "description": "Type of tree entry", "args": [ ], @@ -13224,7 +13543,7 @@ }, { "name": "webUrl", - "description": null, + "description": "Web URL for the sub-module", "args": [ ], @@ -13367,7 +13686,7 @@ "fields": [ { "name": "flatPath", - "description": null, + "description": "Flat path of the entry", "args": [ ], @@ -13385,7 +13704,7 @@ }, { "name": "id", - "description": null, + "description": "ID of the entry", "args": [ ], @@ -13403,7 +13722,7 @@ }, { "name": "lfsOid", - "description": null, + "description": "LFS ID of the blob", "args": [ ], @@ -13417,7 +13736,7 @@ }, { "name": "name", - "description": null, + "description": "Name of the entry", "args": [ ], @@ -13435,7 +13754,7 @@ }, { "name": "path", - "description": null, + "description": "Path of the entry", "args": [ ], @@ -13453,7 +13772,7 @@ }, { "name": "sha", - "description": "Last commit sha for entry", + "description": "Last commit sha for the entry", "args": [ ], @@ -13471,7 +13790,7 @@ }, { "name": "type", - "description": null, + "description": "Type of tree entry", "args": [ ], @@ -13489,7 +13808,7 @@ }, { "name": "webUrl", - "description": null, + "description": "Web URL of the blob", "args": [ ], @@ -14808,7 +15127,7 @@ "fields": [ { "name": "adminMergeRequest", - "description": "Whether or not a user can perform `admin_merge_request` on this resource", + "description": "Indicates the user can perform `admin_merge_request` on this resource", "args": [ ], @@ -14826,7 +15145,7 @@ }, { "name": "cherryPickOnCurrentMergeRequest", - "description": "Whether or not a user can perform `cherry_pick_on_current_merge_request` on this resource", + "description": "Indicates the user can perform `cherry_pick_on_current_merge_request` on this resource", "args": [ ], @@ -14844,7 +15163,7 @@ }, { "name": "createNote", - "description": "Whether or not a user can perform `create_note` on this resource", + "description": "Indicates the user can perform `create_note` on this resource", "args": [ ], @@ -14862,7 +15181,7 @@ }, { "name": "pushToSourceBranch", - "description": "Whether or not a user can perform `push_to_source_branch` on this resource", + "description": "Indicates the user can perform `push_to_source_branch` on this resource", "args": [ ], @@ -14880,7 +15199,7 @@ }, { "name": "readMergeRequest", - "description": "Whether or not a user can perform `read_merge_request` on this resource", + "description": "Indicates the user can perform `read_merge_request` on this resource", "args": [ ], @@ -14898,7 +15217,7 @@ }, { "name": "removeSourceBranch", - "description": "Whether or not a user can perform `remove_source_branch` on this resource", + "description": "Indicates the user can perform `remove_source_branch` on this resource", "args": [ ], @@ -14916,7 +15235,7 @@ }, { "name": "revertOnCurrentMergeRequest", - "description": "Whether or not a user can perform `revert_on_current_merge_request` on this resource", + "description": "Indicates the user can perform `revert_on_current_merge_request` on this resource", "args": [ ], @@ -14934,7 +15253,7 @@ }, { "name": "updateMergeRequest", - "description": "Whether or not a user can perform `update_merge_request` on this resource", + "description": "Indicates the user can perform `update_merge_request` on this resource", "args": [ ], @@ -14995,8 +15314,214 @@ }, { "kind": "OBJECT", - "name": "IssueConnection", - "description": "The connection type for Issue.", + "name": "IssueConnection", + "description": "The connection type for Issue.", + "fields": [ + { + "name": "edges", + "description": "A list of edges.", + "args": [ + + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "IssueEdge", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "nodes", + "description": "A list of nodes.", + "args": [ + + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Issue", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "pageInfo", + "description": "Information to aid in pagination.", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "PageInfo", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "IssueEdge", + "description": "An edge in a connection.", + "fields": [ + { + "name": "cursor", + "description": "A cursor for use in pagination.", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "node", + "description": "The item at the end of the edge.", + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "Issue", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "IssuableState", + "description": "State of a GitLab issue or merge request", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "opened", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "closed", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "locked", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "IssueSort", + "description": "Values for sorting issues", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "updated_desc", + "description": "Updated at descending order", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updated_asc", + "description": "Updated at ascending order", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "created_desc", + "description": "Created at descending order", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "created_asc", + "description": "Created at ascending order", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "DUE_DATE_ASC", + "description": "Due date by ascending order", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "DUE_DATE_DESC", + "description": "Due date by descending order", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "RELATIVE_POSITION_ASC", + "description": "Relative position by ascending order", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "WEIGHT_ASC", + "description": "Weight by ascending order", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "WEIGHT_DESC", + "description": "Weight by descending order", + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "EnvironmentConnection", + "description": "The connection type for Environment.", "fields": [ { "name": "edges", @@ -15009,7 +15534,7 @@ "name": null, "ofType": { "kind": "OBJECT", - "name": "IssueEdge", + "name": "EnvironmentEdge", "ofType": null } }, @@ -15027,7 +15552,7 @@ "name": null, "ofType": { "kind": "OBJECT", - "name": "Issue", + "name": "Environment", "ofType": null } }, @@ -15062,7 +15587,7 @@ }, { "kind": "OBJECT", - "name": "IssueEdge", + "name": "EnvironmentEdge", "description": "An edge in a connection.", "fields": [ { @@ -15091,7 +15616,7 @@ ], "type": { "kind": "OBJECT", - "name": "Issue", + "name": "Environment", "ofType": null }, "isDeprecated": false, @@ -15106,97 +15631,52 @@ "possibleTypes": null }, { - "kind": "ENUM", - "name": "IssuableState", - "description": "State of a GitLab issue or merge request", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "opened", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, + "kind": "OBJECT", + "name": "Environment", + "description": "Describes where code is deployed for a project", + "fields": [ { - "name": "closed", - "description": null, + "name": "id", + "description": "ID of the environment", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, "isDeprecated": false, "deprecationReason": null }, { - "name": "locked", - "description": null, + "name": "name", + "description": "Human-readable name of the environment", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, "isDeprecated": false, "deprecationReason": null } ], - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "IssueSort", - "description": "Values for sorting issues", - "fields": null, "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "updated_desc", - "description": "Updated at descending order", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "updated_asc", - "description": "Updated at ascending order", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "created_desc", - "description": "Created at descending order", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "created_asc", - "description": "Created at ascending order", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "DUE_DATE_ASC", - "description": "Due date by ascending order", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "DUE_DATE_DESC", - "description": "Due date by descending order", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "RELATIVE_POSITION_ASC", - "description": "Relative position by ascending order", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "WEIGHT_ASC", - "description": "Weight by ascending order", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "WEIGHT_DESC", - "description": "Weight by descending order", - "isDeprecated": false, - "deprecationReason": null - } + "interfaces": [ + ], + "enumValues": null, "possibleTypes": null }, { @@ -15330,6 +15810,34 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "gitlabCommit", + "description": "GitLab commit SHA attributed to the Error based on the release version", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "gitlabCommitPath", + "description": "Path to the GitLab page for the GitLab commit attributed to the error", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "id", "description": "ID (global ID) of the error", @@ -15662,6 +16170,127 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "OBJECT", + "name": "GrafanaIntegration", + "description": null, + "fields": [ + { + "name": "createdAt", + "description": "Timestamp of the issue's creation", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Time", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "enabled", + "description": "Indicates whether Grafana integration is enabled", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "grafanaUrl", + "description": "Url for the Grafana host for the Grafana integration", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": "Internal ID of the Grafana integration", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "token", + "description": "API token for the Grafana integration", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updatedAt", + "description": "Timestamp of the issue's last activity", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Time", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, { "kind": "OBJECT", "name": "Metadata", @@ -16604,7 +17233,7 @@ { "kind": "OBJECT", "name": "AwardEmoji", - "description": null, + "description": "An emoji awarded by a user.", "fields": [ { "name": "description", @@ -16948,7 +17577,7 @@ }, { "name": "toggledOn", - "description": "True when the emoji was awarded, false when it was removed", + "description": "Indicates the status of the emoji. True if the toggle awarded the emoji, and false if the toggle removed the emoji.", "args": [ ], @@ -20350,7 +20979,7 @@ { "kind": "INPUT_OBJECT", "name": "EpicTreeNodeFieldsInputType", - "description": null, + "description": "A node of an epic tree.", "fields": null, "inputFields": [ { @@ -20648,20 +21277,20 @@ { "kind": "ENUM", "name": "EpicStateEvent", - "description": "State event of a GitLab Epic", + "description": "State event of an epic", "fields": null, "inputFields": null, "interfaces": null, "enumValues": [ { "name": "REOPEN", - "description": "Reopen the Epic", + "description": "Reopen the epic", "isDeprecated": false, "deprecationReason": null }, { "name": "CLOSE", - "description": "Close the Epic", + "description": "Close the epic", "isDeprecated": false, "deprecationReason": null } diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 9fb39322f5cd38a834e3c83a371861c7d45aa6eb..72fc82444ca49bab45353ed850c1ca49aebac0fe 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -9,10 +9,12 @@ This documentation is self-generated based on GitLab current GraphQL schema. The API can be explored interactively using the [GraphiQL IDE](../index.md#graphiql). +Each table below documents a GraphQL type. Types match loosely to models, but not all +fields and methods on a model are available via GraphQL. -## Objects +## AddAwardEmojiPayload -### AddAwardEmojiPayload +Autogenerated return type of AddAwardEmoji | Name | Type | Description | | --- | ---- | ---------- | @@ -20,7 +22,9 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | `errors` | String! => Array | Reasons why the mutation failed. | | `awardEmoji` | AwardEmoji | The award emoji after mutation | -### AwardEmoji +## AwardEmoji + +An emoji awarded by a user. | Name | Type | Description | | --- | ---- | ---------- | @@ -31,20 +35,20 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | `unicodeVersion` | String! | The unicode version for this emoji | | `user` | User! | The user who awarded the emoji | -### Blob +## Blob | Name | Type | Description | | --- | ---- | ---------- | -| `id` | ID! | | -| `sha` | String! | Last commit sha for entry | -| `name` | String! | | -| `type` | EntryType! | | -| `path` | String! | | -| `flatPath` | String! | | -| `webUrl` | String | | -| `lfsOid` | String | | +| `id` | ID! | ID of the entry | +| `sha` | String! | Last commit sha for the entry | +| `name` | String! | Name of the entry | +| `type` | EntryType! | Type of tree entry | +| `path` | String! | Path of the entry | +| `flatPath` | String! | Flat path of the entry | +| `webUrl` | String | Web URL of the blob | +| `lfsOid` | String | LFS ID of the blob | -### Commit +## Commit | Name | Type | Description | | --- | ---- | ---------- | @@ -60,7 +64,9 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | `author` | User | Author of the commit | | `latestPipeline` | Pipeline | Latest pipeline of the commit | -### CreateDiffNotePayload +## CreateDiffNotePayload + +Autogenerated return type of CreateDiffNote | Name | Type | Description | | --- | ---- | ---------- | @@ -68,7 +74,9 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | `errors` | String! => Array | Reasons why the mutation failed. | | `note` | Note | The note after mutation | -### CreateEpicPayload +## CreateEpicPayload + +Autogenerated return type of CreateEpic | Name | Type | Description | | --- | ---- | ---------- | @@ -76,7 +84,9 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | `errors` | String! => Array | Reasons why the mutation failed. | | `epic` | Epic | The created epic | -### CreateImageDiffNotePayload +## CreateImageDiffNotePayload + +Autogenerated return type of CreateImageDiffNote | Name | Type | Description | | --- | ---- | ---------- | @@ -84,7 +94,9 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | `errors` | String! => Array | Reasons why the mutation failed. | | `note` | Note | The note after mutation | -### CreateNotePayload +## CreateNotePayload + +Autogenerated return type of CreateNote | Name | Type | Description | | --- | ---- | ---------- | @@ -92,7 +104,9 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | `errors` | String! => Array | Reasons why the mutation failed. | | `note` | Note | The note after mutation | -### CreateSnippetPayload +## CreateSnippetPayload + +Autogenerated return type of CreateSnippet | Name | Type | Description | | --- | ---- | ---------- | @@ -100,28 +114,34 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | `errors` | String! => Array | Reasons why the mutation failed. | | `snippet` | Snippet | The snippet after mutation | -### Design +## Design + +A single design | Name | Type | Description | | --- | ---- | ---------- | -| `id` | ID! | | -| `project` | Project! | | -| `issue` | Issue! | | +| `id` | ID! | The ID of this design | +| `project` | Project! | The project the design belongs to | +| `issue` | Issue! | The issue the design belongs to | +| `filename` | String! | The filename of the design | +| `fullPath` | String! | The full path to the design file | +| `image` | String! | The URL of the image | +| `diffRefs` | DiffRefs! | The diff refs for this design | +| `event` | DesignVersionEvent! | How this design was changed in the current version | | `notesCount` | Int! | The total count of user-created notes for this design | -| `filename` | String! | | -| `fullPath` | String! | | -| `event` | DesignVersionEvent! | The change that happened to the design at this version | -| `image` | String! | | -| `diffRefs` | DiffRefs! | | -### DesignCollection +## DesignCollection + +A collection of designs. | Name | Type | Description | | --- | ---- | ---------- | -| `project` | Project! | | -| `issue` | Issue! | | +| `project` | Project! | Project associated with the design collection | +| `issue` | Issue! | Issue associated with the design collection | + +## DesignManagementDeletePayload -### DesignManagementDeletePayload +Autogenerated return type of DesignManagementDelete | Name | Type | Description | | --- | ---- | ---------- | @@ -129,7 +149,9 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | `errors` | String! => Array | Reasons why the mutation failed. | | `version` | DesignVersion | The new version in which the designs are deleted | -### DesignManagementUploadPayload +## DesignManagementUploadPayload + +Autogenerated return type of DesignManagementUpload | Name | Type | Description | | --- | ---- | ---------- | @@ -138,14 +160,16 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | `designs` | Design! => Array | The designs that were uploaded by the mutation | | `skippedDesigns` | Design! => Array | Any designs that were skipped from the upload due to there being no change to their content since their last version | -### DesignVersion +## DesignVersion | Name | Type | Description | | --- | ---- | ---------- | -| `id` | ID! | | -| `sha` | ID! | | +| `id` | ID! | ID of the design version | +| `sha` | ID! | SHA of the design version | -### DestroyNotePayload +## DestroyNotePayload + +Autogenerated return type of DestroyNote | Name | Type | Description | | --- | ---- | ---------- | @@ -153,7 +177,9 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | `errors` | String! => Array | Reasons why the mutation failed. | | `note` | Note | The note after mutation | -### DestroySnippetPayload +## DestroySnippetPayload + +Autogenerated return type of DestroySnippet | Name | Type | Description | | --- | ---- | ---------- | @@ -161,20 +187,20 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | `errors` | String! => Array | Reasons why the mutation failed. | | `snippet` | Snippet | The snippet after mutation | -### DetailedStatus +## DetailedStatus | Name | Type | Description | | --- | ---- | ---------- | -| `group` | String! | | -| `icon` | String! | | -| `favicon` | String! | | -| `detailsPath` | String! | | -| `hasDetails` | Boolean! | | -| `label` | String! | | -| `text` | String! | | -| `tooltip` | String! | | +| `group` | String! | Group of the pipeline status | +| `icon` | String! | Icon of the pipeline status | +| `favicon` | String! | Favicon of the pipeline status | +| `detailsPath` | String! | Path of the details for the pipeline status | +| `hasDetails` | Boolean! | Indicates if the pipeline status has further details | +| `label` | String! | Label of the pipeline status | +| `text` | String! | Text of the pipeline status | +| `tooltip` | String! | Tooltip associated with the pipeline status | -### DiffPosition +## DiffPosition | Name | Type | Description | | --- | ---- | ---------- | @@ -190,7 +216,7 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | `width` | Int | Total width of the image | | `height` | Int | Total height of the image | -### DiffRefs +## DiffRefs | Name | Type | Description | | --- | ---- | ---------- | @@ -198,7 +224,7 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | `baseSha` | String! | Merge base of the branch the comment was made on | | `startSha` | String! | SHA of the branch being compared against | -### Discussion +## Discussion | Name | Type | Description | | --- | ---- | ---------- | @@ -206,7 +232,18 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | `replyId` | ID! | ID used to reply to this discussion | | `createdAt` | Time! | Timestamp of the discussion's creation | -### Epic +## Environment + +Describes where code is deployed for a project + +| Name | Type | Description | +| --- | ---- | ---------- | +| `name` | String! | Human-readable name of the environment | +| `id` | ID! | ID of the environment | + +## Epic + +Represents an epic. | Name | Type | Description | | --- | ---- | ---------- | @@ -239,10 +276,12 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | `relativePosition` | Int | The relative position of the epic in the epic tree | | `relationPath` | String | | | `reference` | String! | | -| `subscribed` | Boolean! | Boolean flag for whether the currently logged in user is subscribed to this epic | +| `subscribed` | Boolean! | Indicates the currently logged in user is subscribed to the epic | | `descendantCounts` | EpicDescendantCount | Number of open and closed descendant epics and issues | -### EpicDescendantCount +## EpicDescendantCount + +Counts of descendent epics. | Name | Type | Description | | --- | ---- | ---------- | @@ -251,7 +290,9 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | `openedIssues` | Int | Number of opened epic issues | | `closedIssues` | Int | Number of closed epic issues | -### EpicIssue +## EpicIssue + +Relationship between an epic and an issue | Name | Type | Description | | --- | ---- | ---------- | @@ -274,7 +315,7 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | `webPath` | String! | Web path of the issue | | `webUrl` | String! | Web URL of the issue | | `relativePosition` | Int | Relative position of the issue (used for positioning in epic tree and issue boards) | -| `subscribed` | Boolean! | Boolean flag for whether the currently logged in user is subscribed to this issue | +| `subscribed` | Boolean! | Indicates the currently logged in user is subscribed to the issue | | `timeEstimate` | Int! | Time estimate of the issue | | `totalTimeSpent` | Int! | Total time reported as spent on the issue | | `closedAt` | Time | Timestamp of when the issue was closed | @@ -283,26 +324,30 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | `taskCompletionStatus` | TaskCompletionStatus! | Task completion status of the issue | | `epic` | Epic | Epic to which this issue belongs | | `weight` | Int | Weight of the issue | -| `designs` | DesignCollection | Deprecated. Use `design_collection`. | +| `designs` | DesignCollection | Deprecated. Use `design_collection` | | `designCollection` | DesignCollection | Collection of design images associated with this issue | | `epicIssueId` | ID! | ID of the epic-issue relation | | `relationPath` | String | URI path of the epic-issue relation | | `id` | ID | Global ID of the epic-issue relation | -### EpicPermissions +## EpicPermissions + +Check permissions for the current user on an epic | Name | Type | Description | | --- | ---- | ---------- | -| `readEpic` | Boolean! | Whether or not a user can perform `read_epic` on this resource | -| `readEpicIid` | Boolean! | Whether or not a user can perform `read_epic_iid` on this resource | -| `updateEpic` | Boolean! | Whether or not a user can perform `update_epic` on this resource | -| `destroyEpic` | Boolean! | Whether or not a user can perform `destroy_epic` on this resource | -| `adminEpic` | Boolean! | Whether or not a user can perform `admin_epic` on this resource | -| `createEpic` | Boolean! | Whether or not a user can perform `create_epic` on this resource | -| `createNote` | Boolean! | Whether or not a user can perform `create_note` on this resource | -| `awardEmoji` | Boolean! | Whether or not a user can perform `award_emoji` on this resource | +| `readEpic` | Boolean! | Indicates the user can perform `read_epic` on this resource | +| `readEpicIid` | Boolean! | Indicates the user can perform `read_epic_iid` on this resource | +| `updateEpic` | Boolean! | Indicates the user can perform `update_epic` on this resource | +| `destroyEpic` | Boolean! | Indicates the user can perform `destroy_epic` on this resource | +| `adminEpic` | Boolean! | Indicates the user can perform `admin_epic` on this resource | +| `createEpic` | Boolean! | Indicates the user can perform `create_epic` on this resource | +| `createNote` | Boolean! | Indicates the user can perform `create_note` on this resource | +| `awardEmoji` | Boolean! | Indicates the user can perform `award_emoji` on this resource | + +## EpicSetSubscriptionPayload -### EpicSetSubscriptionPayload +Autogenerated return type of EpicSetSubscription | Name | Type | Description | | --- | ---- | ---------- | @@ -310,14 +355,27 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | `errors` | String! => Array | Reasons why the mutation failed. | | `epic` | Epic | The epic after mutation | -### EpicTreeReorderPayload +## EpicTreeReorderPayload + +Autogenerated return type of EpicTreeReorder | Name | Type | Description | | --- | ---- | ---------- | | `clientMutationId` | String | A unique identifier for the client performing the mutation. | | `errors` | String! => Array | Reasons why the mutation failed. | -### Group +## GrafanaIntegration + +| Name | Type | Description | +| --- | ---- | ---------- | +| `id` | ID! | Internal ID of the Grafana integration | +| `grafanaUrl` | String! | Url for the Grafana host for the Grafana integration | +| `token` | String! | API token for the Grafana integration | +| `enabled` | Boolean! | Indicates whether Grafana integration is enabled | +| `createdAt` | Time! | Timestamp of the issue's creation | +| `updatedAt` | Time! | Timestamp of the issue's last activity | + +## Group | Name | Type | Description | | --- | ---- | ---------- | @@ -335,18 +393,19 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | `userPermissions` | GroupPermissions! | Permissions for the current user on the resource | | `webUrl` | String! | Web URL of the group | | `avatarUrl` | String | Avatar URL of the group | +| `mentionsDisabled` | Boolean | Indicates if a group is disabled from getting mentioned | | `parent` | Group | Parent group | | `epicsEnabled` | Boolean | Indicates if Epics are enabled for namespace | | `groupTimelogsEnabled` | Boolean | Indicates if Group timelogs are enabled for namespace | | `epic` | Epic | Find a single epic | -### GroupPermissions +## GroupPermissions | Name | Type | Description | | --- | ---- | ---------- | -| `readGroup` | Boolean! | Whether or not a user can perform `read_group` on this resource | +| `readGroup` | Boolean! | Indicates the user can perform `read_group` on this resource | -### Issue +## Issue | Name | Type | Description | | --- | ---- | ---------- | @@ -369,7 +428,7 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | `webPath` | String! | Web path of the issue | | `webUrl` | String! | Web URL of the issue | | `relativePosition` | Int | Relative position of the issue (used for positioning in epic tree and issue boards) | -| `subscribed` | Boolean! | Boolean flag for whether the currently logged in user is subscribed to this issue | +| `subscribed` | Boolean! | Indicates the currently logged in user is subscribed to the issue | | `timeEstimate` | Int! | Time estimate of the issue | | `totalTimeSpent` | Int! | Total time reported as spent on the issue | | `closedAt` | Time | Timestamp of when the issue was closed | @@ -378,23 +437,27 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | `taskCompletionStatus` | TaskCompletionStatus! | Task completion status of the issue | | `epic` | Epic | Epic to which this issue belongs | | `weight` | Int | Weight of the issue | -| `designs` | DesignCollection | Deprecated. Use `design_collection`. | +| `designs` | DesignCollection | Deprecated. Use `design_collection` | | `designCollection` | DesignCollection | Collection of design images associated with this issue | -### IssuePermissions +## IssuePermissions + +Check permissions for the current user on a issue | Name | Type | Description | | --- | ---- | ---------- | -| `readIssue` | Boolean! | Whether or not a user can perform `read_issue` on this resource | -| `adminIssue` | Boolean! | Whether or not a user can perform `admin_issue` on this resource | -| `updateIssue` | Boolean! | Whether or not a user can perform `update_issue` on this resource | -| `createNote` | Boolean! | Whether or not a user can perform `create_note` on this resource | -| `reopenIssue` | Boolean! | Whether or not a user can perform `reopen_issue` on this resource | -| `readDesign` | Boolean! | Whether or not a user can perform `read_design` on this resource | -| `createDesign` | Boolean! | Whether or not a user can perform `create_design` on this resource | -| `destroyDesign` | Boolean! | Whether or not a user can perform `destroy_design` on this resource | +| `readIssue` | Boolean! | Indicates the user can perform `read_issue` on this resource | +| `adminIssue` | Boolean! | Indicates the user can perform `admin_issue` on this resource | +| `updateIssue` | Boolean! | Indicates the user can perform `update_issue` on this resource | +| `createNote` | Boolean! | Indicates the user can perform `create_note` on this resource | +| `reopenIssue` | Boolean! | Indicates the user can perform `reopen_issue` on this resource | +| `readDesign` | Boolean! | Indicates the user can perform `read_design` on this resource | +| `createDesign` | Boolean! | Indicates the user can perform `create_design` on this resource | +| `destroyDesign` | Boolean! | Indicates the user can perform `destroy_design` on this resource | -### IssueSetConfidentialPayload +## IssueSetConfidentialPayload + +Autogenerated return type of IssueSetConfidential | Name | Type | Description | | --- | ---- | ---------- | @@ -402,7 +465,9 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | `errors` | String! => Array | Reasons why the mutation failed. | | `issue` | Issue | The issue after mutation | -### IssueSetDueDatePayload +## IssueSetDueDatePayload + +Autogenerated return type of IssueSetDueDate | Name | Type | Description | | --- | ---- | ---------- | @@ -410,7 +475,9 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | `errors` | String! => Array | Reasons why the mutation failed. | | `issue` | Issue | The issue after mutation | -### IssueSetWeightPayload +## IssueSetWeightPayload + +Autogenerated return type of IssueSetWeight | Name | Type | Description | | --- | ---- | ---------- | @@ -418,7 +485,7 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | `errors` | String! => Array | Reasons why the mutation failed. | | `issue` | Issue | The issue after mutation | -### Label +## Label | Name | Type | Description | | --- | ---- | ---------- | @@ -429,7 +496,9 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | `color` | String! | Background color of the label | | `textColor` | String! | Text color of the label | -### MarkAsSpamSnippetPayload +## MarkAsSpamSnippetPayload + +Autogenerated return type of MarkAsSpamSnippet | Name | Type | Description | | --- | ---- | ---------- | @@ -437,7 +506,7 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | `errors` | String! => Array | Reasons why the mutation failed. | | `snippet` | Snippet | The snippet after mutation | -### MergeRequest +## MergeRequest | Name | Type | Description | | --- | ---- | ---------- | @@ -491,20 +560,24 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | `reference` | String! | Internal reference of the merge request. Returned in shortened format by default | | `taskCompletionStatus` | TaskCompletionStatus! | Completion status of tasks | -### MergeRequestPermissions +## MergeRequestPermissions + +Check permissions for the current user on a merge request | Name | Type | Description | | --- | ---- | ---------- | -| `readMergeRequest` | Boolean! | Whether or not a user can perform `read_merge_request` on this resource | -| `adminMergeRequest` | Boolean! | Whether or not a user can perform `admin_merge_request` on this resource | -| `updateMergeRequest` | Boolean! | Whether or not a user can perform `update_merge_request` on this resource | -| `createNote` | Boolean! | Whether or not a user can perform `create_note` on this resource | -| `pushToSourceBranch` | Boolean! | Whether or not a user can perform `push_to_source_branch` on this resource | -| `removeSourceBranch` | Boolean! | Whether or not a user can perform `remove_source_branch` on this resource | -| `cherryPickOnCurrentMergeRequest` | Boolean! | Whether or not a user can perform `cherry_pick_on_current_merge_request` on this resource | -| `revertOnCurrentMergeRequest` | Boolean! | Whether or not a user can perform `revert_on_current_merge_request` on this resource | +| `readMergeRequest` | Boolean! | Indicates the user can perform `read_merge_request` on this resource | +| `adminMergeRequest` | Boolean! | Indicates the user can perform `admin_merge_request` on this resource | +| `updateMergeRequest` | Boolean! | Indicates the user can perform `update_merge_request` on this resource | +| `createNote` | Boolean! | Indicates the user can perform `create_note` on this resource | +| `pushToSourceBranch` | Boolean! | Indicates the user can perform `push_to_source_branch` on this resource | +| `removeSourceBranch` | Boolean! | Indicates the user can perform `remove_source_branch` on this resource | +| `cherryPickOnCurrentMergeRequest` | Boolean! | Indicates the user can perform `cherry_pick_on_current_merge_request` on this resource | +| `revertOnCurrentMergeRequest` | Boolean! | Indicates the user can perform `revert_on_current_merge_request` on this resource | -### MergeRequestSetAssigneesPayload +## MergeRequestSetAssigneesPayload + +Autogenerated return type of MergeRequestSetAssignees | Name | Type | Description | | --- | ---- | ---------- | @@ -512,7 +585,9 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | `errors` | String! => Array | Reasons why the mutation failed. | | `mergeRequest` | MergeRequest | The merge request after mutation | -### MergeRequestSetLabelsPayload +## MergeRequestSetLabelsPayload + +Autogenerated return type of MergeRequestSetLabels | Name | Type | Description | | --- | ---- | ---------- | @@ -520,7 +595,9 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | `errors` | String! => Array | Reasons why the mutation failed. | | `mergeRequest` | MergeRequest | The merge request after mutation | -### MergeRequestSetLockedPayload +## MergeRequestSetLockedPayload + +Autogenerated return type of MergeRequestSetLocked | Name | Type | Description | | --- | ---- | ---------- | @@ -528,7 +605,9 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | `errors` | String! => Array | Reasons why the mutation failed. | | `mergeRequest` | MergeRequest | The merge request after mutation | -### MergeRequestSetMilestonePayload +## MergeRequestSetMilestonePayload + +Autogenerated return type of MergeRequestSetMilestone | Name | Type | Description | | --- | ---- | ---------- | @@ -536,7 +615,9 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | `errors` | String! => Array | Reasons why the mutation failed. | | `mergeRequest` | MergeRequest | The merge request after mutation | -### MergeRequestSetSubscriptionPayload +## MergeRequestSetSubscriptionPayload + +Autogenerated return type of MergeRequestSetSubscription | Name | Type | Description | | --- | ---- | ---------- | @@ -544,7 +625,9 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | `errors` | String! => Array | Reasons why the mutation failed. | | `mergeRequest` | MergeRequest | The merge request after mutation | -### MergeRequestSetWipPayload +## MergeRequestSetWipPayload + +Autogenerated return type of MergeRequestSetWip | Name | Type | Description | | --- | ---- | ---------- | @@ -552,14 +635,14 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | `errors` | String! => Array | Reasons why the mutation failed. | | `mergeRequest` | MergeRequest | The merge request after mutation | -### Metadata +## Metadata | Name | Type | Description | | --- | ---- | ---------- | | `version` | String! | Version | | `revision` | String! | Revision | -### Milestone +## Milestone | Name | Type | Description | | --- | ---- | ---------- | @@ -572,7 +655,7 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | `createdAt` | Time! | Timestamp of milestone creation | | `updatedAt` | Time! | Timestamp of last milestone update | -### Namespace +## Namespace | Name | Type | Description | | --- | ---- | ---------- | @@ -588,7 +671,7 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | `requestAccessEnabled` | Boolean | Indicates if users can request access to namespace | | `rootStorageStatistics` | RootStorageStatistics | Aggregated storage statistics of the namespace. Only available for root namespaces | -### Note +## Note | Name | Type | Description | | --- | ---- | ---------- | @@ -607,17 +690,19 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | `resolvedAt` | Time | Timestamp of the note's resolution | | `position` | DiffPosition | The position of this note on a diff | -### NotePermissions +## NotePermissions | Name | Type | Description | | --- | ---- | ---------- | -| `readNote` | Boolean! | Whether or not a user can perform `read_note` on this resource | -| `createNote` | Boolean! | Whether or not a user can perform `create_note` on this resource | -| `adminNote` | Boolean! | Whether or not a user can perform `admin_note` on this resource | -| `resolveNote` | Boolean! | Whether or not a user can perform `resolve_note` on this resource | -| `awardEmoji` | Boolean! | Whether or not a user can perform `award_emoji` on this resource | +| `readNote` | Boolean! | Indicates the user can perform `read_note` on this resource | +| `createNote` | Boolean! | Indicates the user can perform `create_note` on this resource | +| `adminNote` | Boolean! | Indicates the user can perform `admin_note` on this resource | +| `resolveNote` | Boolean! | Indicates the user can perform `resolve_note` on this resource | +| `awardEmoji` | Boolean! | Indicates the user can perform `award_emoji` on this resource | -### PageInfo +## PageInfo + +Information about pagination in a connection. | Name | Type | Description | | --- | ---- | ---------- | @@ -626,34 +711,34 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | `startCursor` | String | When paginating backwards, the cursor to continue. | | `endCursor` | String | When paginating forwards, the cursor to continue. | -### Pipeline +## Pipeline | Name | Type | Description | | --- | ---- | ---------- | | `userPermissions` | PipelinePermissions! | Permissions for the current user on the resource | -| `id` | ID! | | -| `iid` | String! | | -| `sha` | String! | | -| `beforeSha` | String | | -| `status` | PipelineStatusEnum! | | -| `detailedStatus` | DetailedStatus! | | +| `id` | ID! | ID of the pipeline | +| `iid` | String! | Internal ID of the pipeline | +| `sha` | String! | SHA of the pipeline's commit | +| `beforeSha` | String | Base SHA of the source branch | +| `status` | PipelineStatusEnum! | Status of the pipeline (CREATED, WAITING_FOR_RESOURCE, PREPARING, PENDING, RUNNING, FAILED, SUCCESS, CANCELED, SKIPPED, MANUAL, SCHEDULED) | +| `detailedStatus` | DetailedStatus! | Detailed status of the pipeline | | `duration` | Int | Duration of the pipeline in seconds | | `coverage` | Float | Coverage percentage | -| `createdAt` | Time! | | -| `updatedAt` | Time! | | -| `startedAt` | Time | | -| `finishedAt` | Time | | -| `committedAt` | Time | | +| `createdAt` | Time! | Timestamp of the pipeline's creation | +| `updatedAt` | Time! | Timestamp of the pipeline's last activity | +| `startedAt` | Time | Timestamp when the pipeline was started | +| `finishedAt` | Time | Timestamp of the pipeline's completion | +| `committedAt` | Time | Timestamp of the pipeline's commit | -### PipelinePermissions +## PipelinePermissions | Name | Type | Description | | --- | ---- | ---------- | -| `updatePipeline` | Boolean! | Whether or not a user can perform `update_pipeline` on this resource | -| `adminPipeline` | Boolean! | Whether or not a user can perform `admin_pipeline` on this resource | -| `destroyPipeline` | Boolean! | Whether or not a user can perform `destroy_pipeline` on this resource | +| `updatePipeline` | Boolean! | Indicates the user can perform `update_pipeline` on this resource | +| `adminPipeline` | Boolean! | Indicates the user can perform `admin_pipeline` on this resource | +| `destroyPipeline` | Boolean! | Indicates the user can perform `destroy_pipeline` on this resource | -### Project +## Project | Name | Type | Description | | --- | ---- | ---------- | @@ -673,7 +758,7 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | `forksCount` | Int! | Number of times the project has been forked | | `createdAt` | Time | Timestamp of the project creation | | `lastActivityAt` | Time | Timestamp of the project last activity | -| `archived` | Boolean | Archived status of the project | +| `archived` | Boolean | Indicates the archived status of the project | | `visibility` | String | Visibility of the project | | `containerRegistryEnabled` | Boolean | Indicates if the project stores Docker container images in a container registry | | `sharedRunnersEnabled` | Boolean | Indicates if shared runners are enabled on the project | @@ -693,6 +778,8 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | `onlyAllowMergeIfAllDiscussionsAreResolved` | Boolean | Indicates if merge requests of the project can only be merged when all the discussions are resolved | | `printingMergeRequestLinkEnabled` | Boolean | Indicates if a link to create or view a merge request should display after a push to Git repositories of the project from the command line | | `removeSourceBranchAfterMerge` | Boolean | Indicates if `Delete source branch` option should be enabled by default for all new merge requests of the project | +| `autocloseReferencedIssues` | Boolean | Indicates if issues referenced by merge requests and commits within the default branch are closed automatically | +| `suggestionCommitMessage` | String | The commit message used to apply merge request suggestions | | `namespace` | Namespace | Namespace of the project | | `group` | Group | Group of the project | | `statistics` | ProjectStatistics | Statistics of the project | @@ -700,56 +787,57 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | `mergeRequest` | MergeRequest | A single merge request of the project | | `issue` | Issue | A single issue of the project | | `sentryDetailedError` | SentryDetailedError | Detailed version of a Sentry error on the project | +| `grafanaIntegration` | GrafanaIntegration | Grafana integration details for the project | | `serviceDeskEnabled` | Boolean | Indicates if the project has service desk enabled. | | `serviceDeskAddress` | String | E-mail address of the service desk. | -### ProjectPermissions - -| Name | Type | Description | -| --- | ---- | ---------- | -| `changeNamespace` | Boolean! | Whether or not a user can perform `change_namespace` on this resource | -| `changeVisibilityLevel` | Boolean! | Whether or not a user can perform `change_visibility_level` on this resource | -| `renameProject` | Boolean! | Whether or not a user can perform `rename_project` on this resource | -| `removeProject` | Boolean! | Whether or not a user can perform `remove_project` on this resource | -| `archiveProject` | Boolean! | Whether or not a user can perform `archive_project` on this resource | -| `removeForkProject` | Boolean! | Whether or not a user can perform `remove_fork_project` on this resource | -| `removePages` | Boolean! | Whether or not a user can perform `remove_pages` on this resource | -| `readProject` | Boolean! | Whether or not a user can perform `read_project` on this resource | -| `createMergeRequestIn` | Boolean! | Whether or not a user can perform `create_merge_request_in` on this resource | -| `readWiki` | Boolean! | Whether or not a user can perform `read_wiki` on this resource | -| `readProjectMember` | Boolean! | Whether or not a user can perform `read_project_member` on this resource | -| `createIssue` | Boolean! | Whether or not a user can perform `create_issue` on this resource | -| `uploadFile` | Boolean! | Whether or not a user can perform `upload_file` on this resource | -| `readCycleAnalytics` | Boolean! | Whether or not a user can perform `read_cycle_analytics` on this resource | -| `downloadCode` | Boolean! | Whether or not a user can perform `download_code` on this resource | -| `downloadWikiCode` | Boolean! | Whether or not a user can perform `download_wiki_code` on this resource | -| `forkProject` | Boolean! | Whether or not a user can perform `fork_project` on this resource | -| `readCommitStatus` | Boolean! | Whether or not a user can perform `read_commit_status` on this resource | -| `requestAccess` | Boolean! | Whether or not a user can perform `request_access` on this resource | -| `createPipeline` | Boolean! | Whether or not a user can perform `create_pipeline` on this resource | -| `createPipelineSchedule` | Boolean! | Whether or not a user can perform `create_pipeline_schedule` on this resource | -| `createMergeRequestFrom` | Boolean! | Whether or not a user can perform `create_merge_request_from` on this resource | -| `createWiki` | Boolean! | Whether or not a user can perform `create_wiki` on this resource | -| `pushCode` | Boolean! | Whether or not a user can perform `push_code` on this resource | -| `createDeployment` | Boolean! | Whether or not a user can perform `create_deployment` on this resource | -| `pushToDeleteProtectedBranch` | Boolean! | Whether or not a user can perform `push_to_delete_protected_branch` on this resource | -| `adminWiki` | Boolean! | Whether or not a user can perform `admin_wiki` on this resource | -| `adminProject` | Boolean! | Whether or not a user can perform `admin_project` on this resource | -| `updatePages` | Boolean! | Whether or not a user can perform `update_pages` on this resource | -| `adminRemoteMirror` | Boolean! | Whether or not a user can perform `admin_remote_mirror` on this resource | -| `createLabel` | Boolean! | Whether or not a user can perform `create_label` on this resource | -| `updateWiki` | Boolean! | Whether or not a user can perform `update_wiki` on this resource | -| `destroyWiki` | Boolean! | Whether or not a user can perform `destroy_wiki` on this resource | -| `createPages` | Boolean! | Whether or not a user can perform `create_pages` on this resource | -| `destroyPages` | Boolean! | Whether or not a user can perform `destroy_pages` on this resource | -| `readPagesContent` | Boolean! | Whether or not a user can perform `read_pages_content` on this resource | -| `adminOperations` | Boolean! | Whether or not a user can perform `admin_operations` on this resource | -| `createSnippet` | Boolean! | Whether or not a user can perform `create_snippet` on this resource | -| `readDesign` | Boolean! | Whether or not a user can perform `read_design` on this resource | -| `createDesign` | Boolean! | Whether or not a user can perform `create_design` on this resource | -| `destroyDesign` | Boolean! | Whether or not a user can perform `destroy_design` on this resource | - -### ProjectStatistics +## ProjectPermissions + +| Name | Type | Description | +| --- | ---- | ---------- | +| `changeNamespace` | Boolean! | Indicates the user can perform `change_namespace` on this resource | +| `changeVisibilityLevel` | Boolean! | Indicates the user can perform `change_visibility_level` on this resource | +| `renameProject` | Boolean! | Indicates the user can perform `rename_project` on this resource | +| `removeProject` | Boolean! | Indicates the user can perform `remove_project` on this resource | +| `archiveProject` | Boolean! | Indicates the user can perform `archive_project` on this resource | +| `removeForkProject` | Boolean! | Indicates the user can perform `remove_fork_project` on this resource | +| `removePages` | Boolean! | Indicates the user can perform `remove_pages` on this resource | +| `readProject` | Boolean! | Indicates the user can perform `read_project` on this resource | +| `createMergeRequestIn` | Boolean! | Indicates the user can perform `create_merge_request_in` on this resource | +| `readWiki` | Boolean! | Indicates the user can perform `read_wiki` on this resource | +| `readProjectMember` | Boolean! | Indicates the user can perform `read_project_member` on this resource | +| `createIssue` | Boolean! | Indicates the user can perform `create_issue` on this resource | +| `uploadFile` | Boolean! | Indicates the user can perform `upload_file` on this resource | +| `readCycleAnalytics` | Boolean! | Indicates the user can perform `read_cycle_analytics` on this resource | +| `downloadCode` | Boolean! | Indicates the user can perform `download_code` on this resource | +| `downloadWikiCode` | Boolean! | Indicates the user can perform `download_wiki_code` on this resource | +| `forkProject` | Boolean! | Indicates the user can perform `fork_project` on this resource | +| `readCommitStatus` | Boolean! | Indicates the user can perform `read_commit_status` on this resource | +| `requestAccess` | Boolean! | Indicates the user can perform `request_access` on this resource | +| `createPipeline` | Boolean! | Indicates the user can perform `create_pipeline` on this resource | +| `createPipelineSchedule` | Boolean! | Indicates the user can perform `create_pipeline_schedule` on this resource | +| `createMergeRequestFrom` | Boolean! | Indicates the user can perform `create_merge_request_from` on this resource | +| `createWiki` | Boolean! | Indicates the user can perform `create_wiki` on this resource | +| `pushCode` | Boolean! | Indicates the user can perform `push_code` on this resource | +| `createDeployment` | Boolean! | Indicates the user can perform `create_deployment` on this resource | +| `pushToDeleteProtectedBranch` | Boolean! | Indicates the user can perform `push_to_delete_protected_branch` on this resource | +| `adminWiki` | Boolean! | Indicates the user can perform `admin_wiki` on this resource | +| `adminProject` | Boolean! | Indicates the user can perform `admin_project` on this resource | +| `updatePages` | Boolean! | Indicates the user can perform `update_pages` on this resource | +| `adminRemoteMirror` | Boolean! | Indicates the user can perform `admin_remote_mirror` on this resource | +| `createLabel` | Boolean! | Indicates the user can perform `create_label` on this resource | +| `updateWiki` | Boolean! | Indicates the user can perform `update_wiki` on this resource | +| `destroyWiki` | Boolean! | Indicates the user can perform `destroy_wiki` on this resource | +| `createPages` | Boolean! | Indicates the user can perform `create_pages` on this resource | +| `destroyPages` | Boolean! | Indicates the user can perform `destroy_pages` on this resource | +| `readPagesContent` | Boolean! | Indicates the user can perform `read_pages_content` on this resource | +| `adminOperations` | Boolean! | Indicates the user can perform `admin_operations` on this resource | +| `createSnippet` | Boolean! | Indicates the user can perform `create_snippet` on this resource | +| `readDesign` | Boolean! | Indicates the user can perform `read_design` on this resource | +| `createDesign` | Boolean! | Indicates the user can perform `create_design` on this resource | +| `destroyDesign` | Boolean! | Indicates the user can perform `destroy_design` on this resource | + +## ProjectStatistics | Name | Type | Description | | --- | ---- | ---------- | @@ -761,7 +849,9 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | `packagesSize` | Int! | Packages size of the project | | `wikiSize` | Int | Wiki size of the project | -### RemoveAwardEmojiPayload +## RemoveAwardEmojiPayload + +Autogenerated return type of RemoveAwardEmoji | Name | Type | Description | | --- | ---- | ---------- | @@ -769,7 +859,7 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | `errors` | String! => Array | Reasons why the mutation failed. | | `awardEmoji` | AwardEmoji | The award emoji after mutation | -### Repository +## Repository | Name | Type | Description | | --- | ---- | ---------- | @@ -778,7 +868,7 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | `exists` | Boolean! | Indicates a corresponding Git repository exists on disk | | `tree` | Tree | Tree of the repository | -### RootStorageStatistics +## RootStorageStatistics | Name | Type | Description | | --- | ---- | ---------- | @@ -789,7 +879,7 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | `packagesSize` | Int! | The packages size in bytes | | `wikiSize` | Int! | The wiki size in bytes | -### SentryDetailedError +## SentryDetailedError | Name | Type | Description | | --- | ---- | ---------- | @@ -814,15 +904,19 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | `lastReleaseLastCommit` | String | Commit the error was last seen | | `firstReleaseShortVersion` | String | Release version the error was first seen | | `lastReleaseShortVersion` | String | Release version the error was last seen | +| `gitlabCommit` | String | GitLab commit SHA attributed to the Error based on the release version | +| `gitlabCommitPath` | String | Path to the GitLab page for the GitLab commit attributed to the error | -### SentryErrorFrequency +## SentryErrorFrequency | Name | Type | Description | | --- | ---- | ---------- | | `time` | Time! | Time the error frequency stats were recorded | | `count` | Int! | Count of errors received since the previously recorded time | -### Snippet +## Snippet + +Represents a snippet entry | Name | Type | Description | | --- | ---- | ---------- | @@ -841,38 +935,40 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | `rawUrl` | String! | Raw URL of the snippet | | `descriptionHtml` | String | The GitLab Flavored Markdown rendering of `description` | -### SnippetPermissions +## SnippetPermissions | Name | Type | Description | | --- | ---- | ---------- | -| `createNote` | Boolean! | Whether or not a user can perform `create_note` on this resource | -| `awardEmoji` | Boolean! | Whether or not a user can perform `award_emoji` on this resource | -| `readSnippet` | Boolean! | Whether or not a user can perform `read_snippet` on this resource | -| `updateSnippet` | Boolean! | Whether or not a user can perform `update_snippet` on this resource | -| `adminSnippet` | Boolean! | Whether or not a user can perform `admin_snippet` on this resource | -| `reportSnippet` | Boolean! | Whether or not a user can perform `report_snippet` on this resource | +| `createNote` | Boolean! | Indicates the user can perform `create_note` on this resource | +| `awardEmoji` | Boolean! | Indicates the user can perform `award_emoji` on this resource | +| `readSnippet` | Boolean! | Indicates the user can perform `read_snippet` on this resource | +| `updateSnippet` | Boolean! | Indicates the user can perform `update_snippet` on this resource | +| `adminSnippet` | Boolean! | Indicates the user can perform `admin_snippet` on this resource | +| `reportSnippet` | Boolean! | Indicates the user can perform `report_snippet` on this resource | -### Submodule +## Submodule | Name | Type | Description | | --- | ---- | ---------- | -| `id` | ID! | | -| `sha` | String! | Last commit sha for entry | -| `name` | String! | | -| `type` | EntryType! | | -| `path` | String! | | -| `flatPath` | String! | | -| `webUrl` | String | | -| `treeUrl` | String | | +| `id` | ID! | ID of the entry | +| `sha` | String! | Last commit sha for the entry | +| `name` | String! | Name of the entry | +| `type` | EntryType! | Type of tree entry | +| `path` | String! | Path of the entry | +| `flatPath` | String! | Flat path of the entry | +| `webUrl` | String | Web URL for the sub-module | +| `treeUrl` | String | Tree URL for the sub-module | + +## TaskCompletionStatus -### TaskCompletionStatus +Completion status of tasks | Name | Type | Description | | --- | ---- | ---------- | | `count` | Int! | Number of total tasks | | `completedCount` | Int! | Number of completed tasks | -### Timelog +## Timelog | Name | Type | Description | | --- | ---- | ---------- | @@ -881,7 +977,9 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | `user` | User! | The user that logged the time | | `issue` | Issue | The issue that logged time was added to | -### Todo +## Todo + +Representing a todo entry | Name | Type | Description | | --- | ---- | ---------- | @@ -895,7 +993,9 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | `state` | TodoStateEnum! | State of the todo | | `createdAt` | Time! | Timestamp this todo was created | -### TodoMarkDonePayload +## TodoMarkDonePayload + +Autogenerated return type of TodoMarkDone | Name | Type | Description | | --- | ---- | ---------- | @@ -903,7 +1003,9 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | `errors` | String! => Array | Reasons why the mutation failed. | | `todo` | Todo! | The requested todo | -### TodoRestorePayload +## TodoRestorePayload + +Autogenerated return type of TodoRestore | Name | Type | Description | | --- | ---- | ---------- | @@ -911,7 +1013,9 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | `errors` | String! => Array | Reasons why the mutation failed. | | `todo` | Todo! | The requested todo | -### TodosMarkAllDonePayload +## TodosMarkAllDonePayload + +Autogenerated return type of TodosMarkAllDone | Name | Type | Description | | --- | ---- | ---------- | @@ -919,34 +1023,40 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | `errors` | String! => Array | Reasons why the mutation failed. | | `updatedIds` | ID! => Array | Ids of the updated todos | -### ToggleAwardEmojiPayload +## ToggleAwardEmojiPayload + +Autogenerated return type of ToggleAwardEmoji | Name | Type | Description | | --- | ---- | ---------- | | `clientMutationId` | String | A unique identifier for the client performing the mutation. | | `errors` | String! => Array | Reasons why the mutation failed. | | `awardEmoji` | AwardEmoji | The award emoji after mutation | -| `toggledOn` | Boolean! | True when the emoji was awarded, false when it was removed | +| `toggledOn` | Boolean! | Indicates the status of the emoji. True if the toggle awarded the emoji, and false if the toggle removed the emoji. | -### Tree +## Tree | Name | Type | Description | | --- | ---- | ---------- | | `lastCommit` | Commit | Last commit for the tree | -### TreeEntry +## TreeEntry + +Represents a directory | Name | Type | Description | | --- | ---- | ---------- | -| `id` | ID! | | -| `sha` | String! | Last commit sha for entry | -| `name` | String! | | -| `type` | EntryType! | | -| `path` | String! | | -| `flatPath` | String! | | -| `webUrl` | String | | +| `id` | ID! | ID of the entry | +| `sha` | String! | Last commit sha for the entry | +| `name` | String! | Name of the entry | +| `type` | EntryType! | Type of tree entry | +| `path` | String! | Path of the entry | +| `flatPath` | String! | Flat path of the entry | +| `webUrl` | String | Web URL for the tree entry (directory) | -### UpdateEpicPayload +## UpdateEpicPayload + +Autogenerated return type of UpdateEpic | Name | Type | Description | | --- | ---- | ---------- | @@ -954,7 +1064,9 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | `errors` | String! => Array | Reasons why the mutation failed. | | `epic` | Epic | The epic after mutation | -### UpdateNotePayload +## UpdateNotePayload + +Autogenerated return type of UpdateNote | Name | Type | Description | | --- | ---- | ---------- | @@ -962,7 +1074,9 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | `errors` | String! => Array | Reasons why the mutation failed. | | `note` | Note | The note after mutation | -### UpdateSnippetPayload +## UpdateSnippetPayload + +Autogenerated return type of UpdateSnippet | Name | Type | Description | | --- | ---- | ---------- | @@ -970,7 +1084,7 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | `errors` | String! => Array | Reasons why the mutation failed. | | `snippet` | Snippet | The snippet after mutation | -### User +## User | Name | Type | Description | | --- | ---- | ---------- | @@ -980,8 +1094,8 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | `avatarUrl` | String! | URL of the user's avatar | | `webUrl` | String! | Web URL of the user | -### UserPermissions +## UserPermissions | Name | Type | Description | | --- | ---- | ---------- | -| `createSnippet` | Boolean! | Whether or not a user can perform `create_snippet` on this resource | +| `createSnippet` | Boolean! | Indicates the user can perform `create_snippet` on this resource | diff --git a/doc/api/group_labels.md b/doc/api/group_labels.md index f3c3a82135446438f8b0474edb1251181454328d..f41f3a0a4026327f9cbd7e3f39bddf1dfb555f6e 100644 --- a/doc/api/group_labels.md +++ b/doc/api/group_labels.md @@ -4,6 +4,9 @@ This API supports managing of [group labels](../user/project/labels.md#project-labels-and-group-labels). It allows to list, create, update, and delete group labels. Furthermore, users can subscribe and unsubscribe to and from group labels. +NOTE: **Note:** +The `description_html` - was added to response JSON in [GitLab 12.7](https://gitlab.com/gitlab-org/gitlab/merge_requests/21413). + ## List group labels Get all labels for a given group. @@ -32,6 +35,7 @@ Example response: "color": "#FF0000", "text_color" : "#FFFFFF", "description": null, + "description_html": null, "open_issues_count": 0, "closed_issues_count": 0, "open_merge_requests_count": 0, @@ -43,6 +47,7 @@ Example response: "color": "#228B22", "text_color" : "#FFFFFF", "description": null, + "description_html": null, "open_issues_count": 0, "closed_issues_count": 0, "open_merge_requests_count": 0, @@ -78,6 +83,7 @@ Example response: "color": "#FF0000", "text_color" : "#FFFFFF", "description": null, + "description_html": null, "open_issues_count": 0, "closed_issues_count": 0, "open_merge_requests_count": 0, @@ -113,6 +119,7 @@ Example response: "color": "#FFA500", "text_color" : "#FFFFFF", "description": "Describes new ideas", + "description_html": "Describes new ideas", "open_issues_count": 0, "closed_issues_count": 0, "open_merge_requests_count": 0, @@ -149,6 +156,7 @@ Example response: "color": "#FFA500", "text_color" : "#FFFFFF", "description": "Describes new ideas", + "description_html": "Describes new ideas", "open_issues_count": 0, "closed_issues_count": 0, "open_merge_requests_count": 0, @@ -204,6 +212,7 @@ Example response: "color": "#FFA500", "text_color" : "#FFFFFF", "description": "Describes new ideas", + "description_html": "Describes new ideas", "open_issues_count": 0, "closed_issues_count": 0, "open_merge_requests_count": 0, @@ -239,6 +248,7 @@ Example response: "color": "#FFA500", "text_color" : "#FFFFFF", "description": "Describes new ideas", + "description_html": "Describes new ideas", "open_issues_count": 0, "closed_issues_count": 0, "open_merge_requests_count": 0, diff --git a/doc/api/group_milestones.md b/doc/api/group_milestones.md index 61edd2522bee64f20995ebd6a20fbb8c33acf6bf..a77f12de5a1b589c494de3483aa992a4b6276c53 100644 --- a/doc/api/group_milestones.md +++ b/doc/api/group_milestones.md @@ -98,7 +98,7 @@ Parameters: ## Delete group milestone -Only for user with developer access to the group. +Only for users with Developer access to the group. ``` DELETE /groups/:id/milestones/:milestone_id diff --git a/doc/api/groups.md b/doc/api/groups.md index 32e2a88f25b7b264f78a2ce5c9bd5ebfda888ebb..f4dfefe3cb72c61f6bb9eda6b7106cfd41edc914 100644 --- a/doc/api/groups.md +++ b/doc/api/groups.md @@ -40,6 +40,7 @@ GET /groups "auto_devops_enabled": null, "subgroup_creation_level": "owner", "emails_disabled": null, + "mentions_disabled": null, "lfs_enabled": true, "avatar_url": "http://localhost:3000/uploads/group/avatar/1/foo.jpg", "web_url": "http://localhost:3000/groups/foo-bar", @@ -73,6 +74,7 @@ GET /groups?statistics=true "auto_devops_enabled": null, "subgroup_creation_level": "owner", "emails_disabled": null, + "mentions_disabled": null, "lfs_enabled": true, "avatar_url": "http://localhost:3000/uploads/group/avatar/1/foo.jpg", "web_url": "http://localhost:3000/groups/foo-bar", @@ -144,6 +146,7 @@ GET /groups/:id/subgroups "auto_devops_enabled": null, "subgroup_creation_level": "owner", "emails_disabled": null, + "mentions_disabled": null, "lfs_enabled": true, "avatar_url": "http://gitlab.example.com/uploads/group/avatar/1/foo.jpg", "web_url": "http://gitlab.example.com/groups/foo-bar", @@ -486,6 +489,7 @@ Parameters: | `auto_devops_enabled` | boolean | no | Default to Auto DevOps pipeline for all projects within this group. | | `subgroup_creation_level` | integer | no | Allowed to create subgroups. Can be `owner` (Owners), or `maintainer` (Maintainers). | | `emails_disabled` | boolean | no | Disable email notifications | +| `mentions_disabled` | boolean | no | Disable the capability of a group from getting mentioned | | `lfs_enabled` | boolean | no | Enable/disable Large File Storage (LFS) for the projects in this group. | | `request_access_enabled` | boolean | no | Allow users to request member access. | | `parent_id` | integer | no | The parent group ID for creating nested group. | @@ -531,6 +535,7 @@ PUT /groups/:id | `auto_devops_enabled` | boolean | no | Default to Auto DevOps pipeline for all projects within this group. | | `subgroup_creation_level` | integer | no | Allowed to create subgroups. Can be `owner` (Owners), or `maintainer` (Maintainers). | | `emails_disabled` | boolean | no | Disable email notifications | +| `mentions_disabled` | boolean | no | Disable the capability of a group from getting mentioned | | `lfs_enabled` (optional) | boolean | no | Enable/disable Large File Storage (LFS) for the projects in this group. | | `request_access_enabled` | boolean | no | Allow users to request member access. | | `file_template_project_id` | integer | no | **(PREMIUM)** The ID of a project to load custom file templates from. | diff --git a/doc/api/issue_links.md b/doc/api/issue_links.md index 9351b3e4dd52ed90f98953a65b439d611196ac53..7c7901d555116c6e6f07c46918d44d5c200962e3 100644 --- a/doc/api/issue_links.md +++ b/doc/api/issue_links.md @@ -48,6 +48,7 @@ Parameters: "web_url": "http://example.com/example/example/issues/14", "confidential": false, "weight": null, + "link_type": "relates_to" } ] ``` @@ -66,6 +67,7 @@ POST /projects/:id/issues/:issue_iid/links | `issue_iid` | integer | yes | The internal ID of a project's issue | | `target_project_id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) of a target project | | `target_issue_iid` | integer/string | yes | The internal ID of a target project's issue | +| `link_type` | string | no | The type of the relation ("relates_to", "blocks", "is_blocked_by"), defaults to "relates_to"). Ignored unless `issue_link_types` feature flag is enabled. | ```bash curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/4/issues/1/links?target_project_id=5&target_issue_iid=1" @@ -134,7 +136,8 @@ Example response: "web_url": "http://example.com/example/example/issues/14", "confidential": false, "weight": null, - } + }, + "link_type": "relates_to" } ``` @@ -151,6 +154,7 @@ DELETE /projects/:id/issues/:issue_iid/links/:issue_link_id | `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user | | `issue_iid` | integer | yes | The internal ID of a project's issue | | `issue_link_id` | integer/string | yes | The ID of an issue relationship | +| `link_type` | string | no | The type of the relation ('relates_to', 'blocks', 'is_blocked_by'), defaults to 'relates_to' | ```json { @@ -213,6 +217,7 @@ DELETE /projects/:id/issues/:issue_iid/links/:issue_link_id "web_url": "http://example.com/example/example/issues/14", "confidential": false, "weight": null, - } + }, + "link_type": "relates_to" } ``` diff --git a/doc/api/issues.md b/doc/api/issues.md index fe551cfb397ae3d64f6f46e42c5f19e01a564355..3c28b55d1d689f47460d8bcbd8c25fdff3ec6569 100644 --- a/doc/api/issues.md +++ b/doc/api/issues.md @@ -12,6 +12,14 @@ are paginated. Read more on [pagination](README.md#pagination). +CAUTION: **Deprecation** +> `reference` attribute in response is deprecated in favour of `references`. +> Introduced [GitLab 12.6](https://gitlab.com/gitlab-org/gitlab/merge_requests/20354) + +NOTE: **Note** +> `references.relative` is relative to the group / project that the issue is being requested. When issue is fetched from its project +> `relative` format would be the same as `short` format and when requested across groups / projects it is expected to be the same as `full` format. + ## List issues Get all issues the authenticated user has access to. By default it @@ -39,7 +47,7 @@ GET /issues?confidential=true | ------------------- | ---------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | | `state` | string | no | Return `all` issues or just those that are `opened` or `closed` | | `labels` | string | no | Comma-separated list of label names, issues must have all labels to be returned. `None` lists all issues with no labels. `Any` lists all issues with at least one label. `No+Label` (Deprecated) lists all issues with no labels. Predefined names are case-insensitive. | -| `with_labels_details` | Boolean | no | If `true`, response will return more details for each label in labels field: `:name`, `:color`, `:description`, `:text_color`. Default is `false`. | +| `with_labels_details` | Boolean | no | If `true`, response will return more details for each label in labels field: `:name`, `:color`, `:description`, `:description_html`, `:text_color`. Default is `false`. The `description_html` attribute was introduced in [GitLab 12.7](https://gitlab.com/gitlab-org/gitlab/merge_requests/21413)| | `milestone` | string | no | The milestone title. `None` lists all issues with no milestone. `Any` lists all issues that have an assigned milestone. | | `scope` | string | no | Return issues for the given scope: `created_by_me`, `assigned_to_me` or `all`. Defaults to `created_by_me`<br> For versions before 11.0, use the now deprecated `created-by-me` or `assigned-to-me` scopes instead.<br> _([Introduced][ce-13004] in GitLab 9.5. [Changed to snake_case][ce-18935] in GitLab 11.0)_ | | `author_id` | integer | no | Return issues created by the given user `id`. Mutually exclusive with `author_username`. Combine with `scope=all` or `scope=assigned_to_me`. _([Introduced][ce-13004] in GitLab 9.5)_ | @@ -121,7 +129,12 @@ Example response: "merge_requests_count": 0, "user_notes_count": 1, "due_date": "2016-07-22", - "web_url": "http://example.com/example/example/issues/6", + "web_url": "http://example.com/my-group/my-project/issues/6", + "references": { + "short": "#6", + "relative": "my-group/my-project#6", + "full": "my-group/my-project#6" + }, "time_stats": { "time_estimate": 0, "total_time_spent": 0, @@ -190,7 +203,7 @@ GET /groups/:id/issues?confidential=true | `id` | integer/string | yes | The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) owned by the authenticated user | | `state` | string | no | Return all issues or just those that are `opened` or `closed` | | `labels` | string | no | Comma-separated list of label names, issues must have all labels to be returned. `None` lists all issues with no labels. `Any` lists all issues with at least one label. `No+Label` (Deprecated) lists all issues with no labels. Predefined names are case-insensitive. | -| `with_labels_details` | Boolean | no | If `true`, response will return more details for each label in labels field: `:name`, `:color`, `:description`, `:text_color`. Default is `false`. | +| `with_labels_details` | Boolean | no | If `true`, response will return more details for each label in labels field: `:name`, `:color`, `:description`, `:description_html`, `:text_color`. Default is `false`. The `description_html` attribute was introduced in [GitLab 12.7](https://gitlab.com/gitlab-org/gitlab/merge_requests/21413) | | `iids[]` | integer array | no | Return only the issues having the given `iid` | | `milestone` | string | no | The milestone title. `None` lists all issues with no milestone. `Any` lists all issues that have an assigned milestone. | | `scope` | string | no | Return issues for the given scope: `created_by_me`, `assigned_to_me` or `all`.<br> For versions before 11.0, use the now deprecated `created-by-me` or `assigned-to-me` scopes instead.<br> _([Introduced][ce-13004] in GitLab 9.5. [Changed to snake_case][ce-18935] in GitLab 11.0)_ | @@ -270,7 +283,12 @@ Example response: "closed_by" : null, "user_notes_count": 1, "due_date": null, - "web_url": "http://example.com/example/example/issues/1", + "web_url": "http://example.com/my-group/my-project/issues/1", + "references": { + "short": "#1", + "relative": "my-project#1", + "full": "my-group/my-project#1" + }, "time_stats": { "time_estimate": 0, "total_time_spent": 0, @@ -340,7 +358,7 @@ GET /projects/:id/issues?confidential=true | `iids[]` | integer array | no | Return only the milestone having the given `iid` | | `state` | string | no | Return all issues or just those that are `opened` or `closed` | | `labels` | string | no | Comma-separated list of label names, issues must have all labels to be returned. `None` lists all issues with no labels. `Any` lists all issues with at least one label. `No+Label` (Deprecated) lists all issues with no labels. Predefined names are case-insensitive. | -| `with_labels_details` | Boolean | no | If `true`, response will return more details for each label in labels field: `:name`, `:color`, `:description`, `:text_color`. Default is `false`. | +| `with_labels_details` | Boolean | no | If `true`, response will return more details for each label in labels field: `:name`, `:color`, `:description`, `:description_html`, `:text_color`. Default is `false`. `description_html` Introduced in [GitLab 12.7](https://gitlab.com/gitlab-org/gitlab/merge_requests/21413) | | `milestone` | string | no | The milestone title. `None` lists all issues with no milestone. `Any` lists all issues that have an assigned milestone. | | `scope` | string | no | Return issues for the given scope: `created_by_me`, `assigned_to_me` or `all`.<br> For versions before 11.0, use the now deprecated `created-by-me` or `assigned-to-me` scopes instead.<br> _([Introduced][ce-13004] in GitLab 9.5. [Changed to snake_case][ce-18935] in GitLab 11.0)_ | | `author_id` | integer | no | Return issues created by the given user `id`. Mutually exclusive with `author_username`. Combine with `scope=all` or `scope=assigned_to_me`. _([Introduced][ce-13004] in GitLab 9.5)_ | @@ -426,7 +444,12 @@ Example response: }, "user_notes_count": 1, "due_date": "2016-07-22", - "web_url": "http://example.com/example/example/issues/1", + "web_url": "http://example.com/my-group/my-project/issues/1", + "references": { + "short": "#1", + "relative": "#1", + "full": "my-group/my-project#1" + }, "time_stats": { "time_estimate": 0, "total_time_spent": 0, @@ -543,7 +566,12 @@ Example response: "subscribed": false, "user_notes_count": 1, "due_date": null, - "web_url": "http://example.com/example/example/issues/1", + "web_url": "http://example.com/my-group/my-project/issues/1", + "references": { + "short": "#1", + "relative": "#1", + "full": "my-group/my-project#1" + }, "time_stats": { "time_estimate": 0, "total_time_spent": 0, @@ -668,7 +696,12 @@ Example response: "subscribed" : true, "user_notes_count": 0, "due_date": null, - "web_url": "http://example.com/example/example/issues/14", + "web_url": "http://example.com/my-group/my-project/issues/14", + "references": { + "short": "#14", + "relative": "#14", + "full": "my-group/my-project#14" + }, "time_stats": { "time_estimate": 0, "total_time_spent": 0, @@ -778,7 +811,12 @@ Example response: "subscribed" : true, "user_notes_count": 0, "due_date": "2016-07-22", - "web_url": "http://example.com/example/example/issues/15", + "web_url": "http://example.com/my-group/my-project/issues/15", + "references": { + "short": "#15", + "relative": "#15", + "full": "my-group/my-project#15" + }, "time_stats": { "time_estimate": 0, "total_time_spent": 0, @@ -900,7 +938,12 @@ Example response: "web_url": "https://gitlab.example.com/solon.cremin" }, "due_date": null, - "web_url": "http://example.com/example/example/issues/11", + "web_url": "http://example.com/my-group/my-project/issues/11", + "references": { + "short": "#11", + "relative": "#11", + "full": "my-group/my-project#11" + }, "time_stats": { "time_estimate": 0, "total_time_spent": 0, @@ -1001,7 +1044,12 @@ Example response: "web_url": "https://gitlab.example.com/solon.cremin" }, "due_date": null, - "web_url": "http://example.com/example/example/issues/11", + "web_url": "http://example.com/my-group/my-project/issues/11", + "references": { + "short": "#11", + "relative": "#11", + "full": "my-group/my-project#11" + }, "time_stats": { "time_estimate": 0, "total_time_spent": 0, @@ -1095,7 +1143,12 @@ Example response: }, "subscribed": false, "due_date": null, - "web_url": "http://example.com/example/example/issues/12", + "web_url": "http://example.com/my-group/my-project/issues/12", + "references": { + "short": "#12", + "relative": "#12", + "full": "my-group/my-project#12" + }, "confidential": false, "discussion_locked": false, "task_completion_status":{ @@ -1197,7 +1250,12 @@ Example response: "downvotes": 0, "merge_requests_count": 0, "due_date": null, - "web_url": "http://example.com/example/example/issues/110", + "web_url": "http://example.com/my-group/my-project/issues/10", + "references": { + "short": "#10", + "relative": "#10", + "full": "my-group/my-project#10" + }, "confidential": false, "discussion_locked": false, "task_completion_status":{ @@ -1436,6 +1494,11 @@ Example response: "force_remove_source_branch": false, "reference": "!11", "web_url": "https://gitlab.example.com/twitter/flight/merge_requests/4", + "references": { + "short": "!4", + "relative": "!4", + "full": "twitter/flight!4" + }, "time_stats": { "time_estimate": 0, "total_time_spent": 0, @@ -1566,6 +1629,12 @@ Example response: "should_remove_source_branch": null, "force_remove_source_branch": false, "web_url": "https://gitlab.example.com/gitlab-org/gitlab-test/merge_requests/6432", + "reference": "!6432", + "references": { + "short": "!6432", + "relative": "!6432", + "full": "gitlab-org/gitlab-test!6432" + }, "time_stats": { "time_estimate": 0, "total_time_spent": 0, diff --git a/doc/api/keys.md b/doc/api/keys.md index 5dedb630a27637dd5f9fdf86b998aa42b31790af..30b0cda6c8bcfac8b1d60eeab2782f8cb3fb96ac 100644 --- a/doc/api/keys.md +++ b/doc/api/keys.md @@ -128,3 +128,65 @@ Example response: } } ``` + +Deploy Keys are bound to the creating user, so if you query with a deploy key +fingerprint you get additional information about the projects using that key: + +```sh +curl --header "PRIVATE-TOKEN: <your_access_token>" 'https://gitlab.example.com/api/v4/keys?fingerprint=SHA256%3AnUhzNyftwADy8AH3wFY31tAKs7HufskYTte2aXo%2FlCg +``` + +Example response: + +```json +{ + "id": 1, + "title": "Sample key 1", + "key": "ssh-rsa AAAAB3NzaC1yc2EAAAABJQAAAIEAiPWx6WM4lhHNedGfBpPJNPpZ7yKu+dnn1SJejgt1016k6YjzGGphH2TUxwKzxcKDKKezwkpfnxPkSMkuEspGRt/aZZ9wa++Oi7Qkr8prgHc4soW6NUlfDzpvZK2H5E7eQaSeP3SAwGmQKUFHCddNaP0L+hM7zhFNzjFvpaMgJw0=", + "created_at": "2019-11-14T15:11:13.222Z", + "user": { + "id": 1, + "name": "Administrator", + "username": "root", + "state": "active", + "avatar_url": "https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "web_url": "http://0.0.0.0:3000/root", + "created_at": "2019-11-14T15:09:34.831Z", + "bio": null, + "location": null, + "public_email": "", + "skype": "", + "linkedin": "", + "twitter": "", + "website_url": "", + "organization": null, + "last_sign_in_at": "2019-11-16T22:41:26.663Z", + "confirmed_at": "2019-11-14T15:09:34.575Z", + "last_activity_on": "2019-11-20", + "email": "admin@example.com", + "theme_id": 1, + "color_scheme_id": 1, + "projects_limit": 100000, + "current_sign_in_at": "2019-11-19T14:42:18.078Z", + "identities": [ + ], + "can_create_group": true, + "can_create_project": true, + "two_factor_enabled": false, + "external": false, + "private_profile": false, + "shared_runners_minutes_limit": null, + "extra_shared_runners_minutes_limit": null + }, + "deploy_keys_projects": [ + { + "id": 1, + "deploy_key_id": 1, + "project_id": 1, + "created_at": "2020-01-09T07:32:52.453Z", + "updated_at": "2020-01-09T07:32:52.453Z", + "can_push": false + } + ] +} +``` diff --git a/doc/api/labels.md b/doc/api/labels.md index 525dbe02e5ff8af506fd2ad430388b4a37f7ff85..ac5156e8c2069b3db722c6770f41335f405cfc91 100644 --- a/doc/api/labels.md +++ b/doc/api/labels.md @@ -1,5 +1,8 @@ # Labels API +NOTE: **Note:** +The `description_html` - was added to response JSON in [GitLab 12.7](https://gitlab.com/gitlab-org/gitlab/merge_requests/21413). + ## List labels Get all labels for a given project. @@ -28,6 +31,7 @@ Example response: "color" : "#d9534f", "text_color" : "#FFFFFF", "description": "Bug reported by user", + "description_html": "Bug reported by user", "open_issues_count": 1, "closed_issues_count": 0, "open_merge_requests_count": 1, @@ -41,6 +45,7 @@ Example response: "text_color" : "#FFFFFF", "name" : "confirmed", "description": "Confirmed issue", + "description_html": "Confirmed issue", "open_issues_count": 2, "closed_issues_count": 5, "open_merge_requests_count": 0, @@ -54,6 +59,7 @@ Example response: "color" : "#d9534f", "text_color" : "#FFFFFF", "description": "Critical issue. Need fix ASAP", + "description_html": "Critical issue. Need fix ASAP", "open_issues_count": 1, "closed_issues_count": 3, "open_merge_requests_count": 1, @@ -67,6 +73,7 @@ Example response: "color" : "#f0ad4e", "text_color" : "#FFFFFF", "description": "Issue about documentation", + "description_html": "Issue about documentation", "open_issues_count": 1, "closed_issues_count": 0, "open_merge_requests_count": 2, @@ -80,6 +87,7 @@ Example response: "text_color" : "#FFFFFF", "name" : "enhancement", "description": "Enhancement proposal", + "description_html": "Enhancement proposal", "open_issues_count": 1, "closed_issues_count": 0, "open_merge_requests_count": 1, @@ -117,6 +125,7 @@ Example response: "color" : "#d9534f", "text_color" : "#FFFFFF", "description": "Bug reported by user", + "description_html": "Bug reported by user", "open_issues_count": 1, "closed_issues_count": 0, "open_merge_requests_count": 1, @@ -155,6 +164,7 @@ Example response: "color" : "#5843AD", "text_color" : "#FFFFFF", "description":null, + "description_html":null, "open_issues_count": 0, "closed_issues_count": 0, "open_merge_requests_count": 0, @@ -214,6 +224,7 @@ Example response: "color" : "#8E44AD", "text_color" : "#FFFFFF", "description": "Documentation", + "description_html": "Documentation", "open_issues_count": 1, "closed_issues_count": 0, "open_merge_requests_count": 2, @@ -252,6 +263,7 @@ Example response: "name" : "documentation", "color" : "#8E44AD", "description": "Documentation", + "description_html": "Documentation", "open_issues_count": 1, "closed_issues_count": 0, "open_merge_requests_count": 2, @@ -289,6 +301,7 @@ Example response: "color" : "#d9534f", "text_color" : "#FFFFFF", "description": "Bug reported by user", + "description_html": "Bug reported by user", "open_issues_count": 1, "closed_issues_count": 0, "open_merge_requests_count": 1, diff --git a/doc/api/merge_request_approvals.md b/doc/api/merge_request_approvals.md index 264e198c59676ccbe2b03ab127d3d0794fef5b7c..4552a56d808c290bd70a8114a597142434f6c9e5 100644 --- a/doc/api/merge_request_approvals.md +++ b/doc/api/merge_request_approvals.md @@ -6,7 +6,7 @@ Configuration for approvals on all Merge Requests (MR) in the project. Must be a ### Get Configuration ->**Note:** This API endpoint is only available on 10.6 Starter and above. +> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/183) in [GitLab Starter](https://about.gitlab.com/pricing/) 10.6. You can request information about a project's approval configuration using the following endpoint: @@ -31,7 +31,7 @@ GET /projects/:id/approvals ### Change configuration ->**Note:** This API endpoint is only available on 10.6 Starter and above. +> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/183) in [GitLab Starter](https://about.gitlab.com/pricing/) 10.6. If you are allowed to, you can change approval configuration using the following endpoint: @@ -63,7 +63,7 @@ POST /projects/:id/approvals ### Get project-level rules ->**Note:** This API endpoint is only available on 12.3 Starter and above. +> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/11877) in [GitLab Starter](https://about.gitlab.com/pricing/) 12.3. You can request information about a project's approval rules using the following endpoint: @@ -137,7 +137,7 @@ GET /projects/:id/approval_rules ### Create project-level rule ->**Note:** This API endpoint is only available on 12.3 Starter and above. +> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/11877) in [GitLab Starter](https://about.gitlab.com/pricing/) 12.3. You can create project approval rules using the following endpoint: @@ -213,7 +213,7 @@ POST /projects/:id/approval_rules ### Update project-level rule ->**Note:** This API endpoint is only available on 12.3 Starter and above. +> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/11877) in [GitLab Starter](https://about.gitlab.com/pricing/) 12.3. You can update project approval rules using the following endpoint: @@ -292,7 +292,7 @@ PUT /projects/:id/approval_rules/:approval_rule_id ### Delete project-level rule ->**Note:** This API endpoint is only available on 12.3 Starter and above. +> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/11877) in [GitLab Starter](https://about.gitlab.com/pricing/) 12.3. You can delete project approval rules using the following endpoint: @@ -310,7 +310,7 @@ DELETE /projects/:id/approval_rules/:approval_rule_id ### Change allowed approvers >**Note:** This API endpoint has been deprecated. Please use Approval Rule API instead. ->**Note:** This API endpoint is only available on 10.6 Starter and above. +> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/183) in [GitLab Starter](https://about.gitlab.com/pricing/) 10.6. If you are allowed to, you can change approvers and approver groups using the following endpoint: @@ -373,7 +373,7 @@ Configuration for approvals on a specific Merge Request. Must be authenticated f ### Get Configuration ->**Note:** This API endpoint is only available on 8.9 Starter and above. +> Introduced in [GitLab Starter](https://about.gitlab.com/pricing/) 8.9. You can request information about a merge request's approval status using the following endpoint: @@ -419,7 +419,7 @@ GET /projects/:id/merge_requests/:merge_request_iid/approvals ### Change approval configuration ->**Note:** This API endpoint is only available on 10.6 Starter and above. +> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/183) in [GitLab Starter](https://about.gitlab.com/pricing/) 10.6. If you are allowed to, you can change `approvals_required` using the following endpoint: @@ -456,7 +456,7 @@ POST /projects/:id/merge_requests/:merge_request_iid/approvals ### Change allowed approvers for Merge Request >**Note:** This API endpoint has been deprecated. Please use Approval Rule API instead. ->**Note:** This API endpoint is only available on 10.6 Starter and above. +> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/183) in [GitLab Starter](https://about.gitlab.com/pricing/) 10.6. If you are allowed to, you can change approvers and approver groups using the following endpoint: @@ -598,7 +598,7 @@ This includes additional information about the users who have already approved ### Get merge request level rules ->**Note:** This API endpoint is only available on 12.3 Starter and above. +> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/13712) in [GitLab Starter](https://about.gitlab.com/pricing/) 12.3. You can request information about a merge request's approval rules using the following endpoint: @@ -674,7 +674,7 @@ GET /projects/:id/merge_requests/:merge_request_iid/approval_rules ### Create merge request level rule ->**Note:** This API endpoint is only available on 12.3 Starter and above. +> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/11877) in [GitLab Starter](https://about.gitlab.com/pricing/) 12.3. You can create merge request approval rules using the following endpoint: @@ -757,12 +757,12 @@ will be used. ### Update merge request level rule ->**Note:** This API endpoint is only available on 12.3 Starter and above. +> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/11877) in [GitLab Starter](https://about.gitlab.com/pricing/) 12.3. You can update merge request approval rules using the following endpoint: ``` -PUT /projects/:id/merge_request/:merge_request_iid/approval_rules/:approval_rule_id +PUT /projects/:id/merge_requests/:merge_request_iid/approval_rules/:approval_rule_id ``` **Important:** Approvers and groups not in the `users`/`groups` param will be **removed** @@ -841,7 +841,7 @@ These are system generated rules. ### Delete merge request level rule ->**Note:** This API endpoint is only available on 12.3 Starter and above. +> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/11877) in [GitLab Starter](https://about.gitlab.com/pricing/) 12.3. You can delete merge request approval rules using the following endpoint: @@ -862,7 +862,7 @@ These are system generated rules. ## Approve Merge Request ->**Note:** This API endpoint is only available on 8.9 Starter and above. +> Introduced in [GitLab Starter](https://about.gitlab.com/pricing/) 8.9. If you are allowed to, you can approve a merge request using the following endpoint: @@ -925,7 +925,7 @@ does not match, the response code will be `409`. ## Unapprove Merge Request ->**Note:** This API endpoint is only available on 9.0 Starter and above. +>Introduced in [GitLab Starter](https://about.gitlab.com/pricing/) 9.0. If you did approve a merge request, you can unapprove it using the following endpoint: diff --git a/doc/api/merge_requests.md b/doc/api/merge_requests.md index 541aa03450f5ebfcf7d0002838cb197252d65fbd..d85310de1598d9252423f3942bea5ebb168e44ad 100644 --- a/doc/api/merge_requests.md +++ b/doc/api/merge_requests.md @@ -2,6 +2,14 @@ Every API call to merge requests must be authenticated. +CAUTION: **Deprecation** +> `reference` attribute in response is deprecated in favour of `references`. +> Introduced [GitLab 12.6](https://gitlab.com/gitlab-org/gitlab/merge_requests/20354) + +NOTE: **Note** +> `references.relative` is relative to the group / project that the merge request is being requested. When merge request is fetched from its project +> `relative` format would be the same as `short` format and when requested across groups / projects it is expected to be the same as `full` format. + ## List merge requests > [Introduced][ce-13060] in GitLab 9.5. @@ -37,6 +45,7 @@ Parameters: | `milestone` | string | no | Return merge requests for a specific milestone. `None` returns merge requests with no milestone. `Any` returns merge requests that have an assigned milestone. | | `view` | string | no | If `simple`, returns the `iid`, URL, title, description, and basic state of merge request | | `labels` | string | no | Return merge requests matching a comma separated list of labels. `None` lists all merge requests with no labels. `Any` lists all merge requests with at least one label. `No+Label` (Deprecated) lists all merge requests with no labels. Predefined names are case-insensitive. | +| `with_labels_details` | Boolean | no | If `true`, response will return more details for each label in labels field: `:name`, `:color`, `:description`, `:description_html`, `:text_color`. Default is `false`. Introduced in [GitLab 12.7](https://gitlab.com/gitlab-org/gitlab/merge_requests/21413) | | `created_after` | datetime | no | Return merge requests created on or after the given time | | `created_before` | datetime | no | Return merge requests created on or before the given time | | `updated_after` | datetime | no | Return merge requests updated on or after the given time | @@ -134,6 +143,11 @@ Parameters: "allow_collaboration": false, "allow_maintainer_to_push": false, "web_url": "http://gitlab.example.com/my-group/my-project/merge_requests/1", + "references": { + "short": "!1", + "relative": "my-group/my-project!1", + "full": "my-group/my-project!1" + }, "time_stats": { "time_estimate": 0, "total_time_spent": 0, @@ -200,6 +214,7 @@ Parameters: | `milestone` | string | no | Return merge requests for a specific milestone. `None` returns merge requests with no milestone. `Any` returns merge requests that have an assigned milestone. | | `view` | string | no | If `simple`, returns the `iid`, URL, title, description, and basic state of merge request | | `labels` | string | no | Return merge requests matching a comma separated list of labels. `None` lists all merge requests with no labels. `Any` lists all merge requests with at least one label. `No+Label` (Deprecated) lists all merge requests with no labels. Predefined names are case-insensitive. | +| `with_labels_details` | Boolean | no | If `true`, response will return more details for each label in labels field: `:name`, `:color`, `:description`, `:description_html`, `:text_color`. Default is `false`. Introduced in [GitLab 12.7](https://gitlab.com/gitlab-org/gitlab/merge_requests/21413) | | `created_after` | datetime | no | Return merge requests created on or after the given time | | `created_before` | datetime | no | Return merge requests created on or before the given time | | `updated_after` | datetime | no | Return merge requests updated on or after the given time | @@ -296,6 +311,11 @@ Parameters: "allow_collaboration": false, "allow_maintainer_to_push": false, "web_url": "http://gitlab.example.com/my-group/my-project/merge_requests/1", + "references": { + "short": "!1", + "relative": "!1", + "full": "my-group/my-project!1" + }, "time_stats": { "time_estimate": 0, "total_time_spent": 0, @@ -355,6 +375,7 @@ Parameters: | `milestone` | string | no | Return merge requests for a specific milestone. `None` returns merge requests with no milestone. `Any` returns merge requests that have an assigned milestone. | | `view` | string | no | If `simple`, returns the `iid`, URL, title, description, and basic state of merge request | | `labels` | string | no | Return merge requests matching a comma separated list of labels. `None` lists all merge requests with no labels. `Any` lists all merge requests with at least one label. `No+Label` (Deprecated) lists all merge requests with no labels. Predefined names are case-insensitive. | +| `with_labels_details` | Boolean | no | If `true`, response will return more details for each label in labels field: `:name`, `:color`, `:description`, `:description_html`, `:text_color`. Default is `false`. Introduced in [GitLab 12.7](https://gitlab.com/gitlab-org/gitlab/merge_requests/21413)| | `created_after` | datetime | no | Return merge requests created on or after the given time | | `created_before` | datetime | no | Return merge requests created on or before the given time | | `updated_after` | datetime | no | Return merge requests updated on or after the given time | @@ -448,6 +469,11 @@ Parameters: "should_remove_source_branch": true, "force_remove_source_branch": false, "web_url": "http://gitlab.example.com/my-group/my-project/merge_requests/1", + "references": { + "short": "!1", + "relative": "my-project!1", + "full": "my-group/my-project!1" + }, "time_stats": { "time_estimate": 0, "total_time_spent": 0, @@ -524,7 +550,7 @@ Parameters: }, "user" : { "can_merge" : false - } + }, "assignee": { "id": 1, "name": "Administrator", @@ -574,6 +600,11 @@ Parameters: "allow_collaboration": false, "allow_maintainer_to_push": false, "web_url": "http://gitlab.example.com/my-group/my-project/merge_requests/1", + "references": { + "short": "!1", + "relative": "!1", + "full": "my-group/my-project!1" + }, "time_stats": { "time_estimate": 0, "total_time_spent": 0, @@ -779,7 +810,12 @@ Parameters: "should_remove_source_branch": true, "force_remove_source_branch": false, "squash": false, - "web_url": "http://example.com/example/example/merge_requests/1", + "web_url": "http://gitlab.example.com/my-group/my-project/merge_requests/1", + "references": { + "short": "!1", + "relative": "!1", + "full": "my-group/my-project!1" + }, "discussion_locked": false, "time_stats": { "time_estimate": 0, @@ -989,6 +1025,11 @@ order for it to take effect: "allow_collaboration": false, "allow_maintainer_to_push": false, "web_url": "http://gitlab.example.com/my-group/my-project/merge_requests/1", + "references": { + "short": "!1", + "relative": "!1", + "full": "my-group/my-project!1" + }, "time_stats": { "time_estimate": 0, "total_time_spent": 0, @@ -1143,6 +1184,11 @@ Must include at least one non-required attribute from above. "allow_collaboration": false, "allow_maintainer_to_push": false, "web_url": "http://gitlab.example.com/my-group/my-project/merge_requests/1", + "references": { + "short": "!1", + "relative": "!1", + "full": "my-group/my-project!1" + }, "time_stats": { "time_estimate": 0, "total_time_spent": 0, @@ -1313,6 +1359,11 @@ Parameters: "allow_collaboration": false, "allow_maintainer_to_push": false, "web_url": "http://gitlab.example.com/my-group/my-project/merge_requests/1", + "references": { + "short": "!1", + "relative": "!1", + "full": "my-group/my-project!1" + }, "time_stats": { "time_estimate": 0, "total_time_spent": 0, @@ -1486,6 +1537,11 @@ Parameters: "allow_collaboration": false, "allow_maintainer_to_push": false, "web_url": "http://gitlab.example.com/my-group/my-project/merge_requests/1", + "references": { + "short": "!1", + "relative": "!1", + "full": "my-group/my-project!1" + }, "time_stats": { "time_estimate": 0, "total_time_spent": 0, @@ -1557,6 +1613,7 @@ PUT /projects/:id/merge_requests/:merge_request_iid/rebase | --------- | ---- | -------- | ----------- | | `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 | +| `skip_ci` | boolean | no | Set to `true` to skip creating a CI pipeline | ```bash curl --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/projects/76/merge_requests/1/rebase @@ -1772,6 +1829,11 @@ Example response: "allow_collaboration": false, "allow_maintainer_to_push": false, "web_url": "http://gitlab.example.com/my-group/my-project/merge_requests/1", + "references": { + "short": "!1", + "relative": "!1", + "full": "my-group/my-project!1" + }, "time_stats": { "time_estimate": 0, "total_time_spent": 0, @@ -1918,6 +1980,11 @@ Example response: "allow_collaboration": false, "allow_maintainer_to_push": false, "web_url": "http://gitlab.example.com/my-group/my-project/merge_requests/1", + "references": { + "short": "!1", + "relative": "!1", + "full": "my-group/my-project!1" + }, "time_stats": { "time_estimate": 0, "total_time_spent": 0, @@ -2078,7 +2145,12 @@ Example response: "should_remove_source_branch": true, "force_remove_source_branch": false, "squash": false, - "web_url": "http://example.com/example/example/merge_requests/1" + "web_url": "http://example.com/my-group/my-project/merge_requests/1", + "references": { + "short": "!1", + "relative": "!1", + "full": "my-group/my-project!1" + }, }, "target_url": "https://gitlab.example.com/gitlab-org/gitlab-ci/merge_requests/7", "body": "Et voluptas laudantium minus nihil recusandae ut accusamus earum aut non.", diff --git a/doc/api/milestones.md b/doc/api/milestones.md index 448444704304abe604d3a62da17fa02571626066..f3a1b7323ec441dc4abff4a62445451685ef62c1 100644 --- a/doc/api/milestones.md +++ b/doc/api/milestones.md @@ -96,7 +96,7 @@ Parameters: ## Delete project milestone -Only for user with developer access to the project. +Only for users with Developer access to the project. ``` DELETE /projects/:id/milestones/:milestone_id @@ -137,7 +137,7 @@ Parameters: > [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/issues/53861) in GitLab 11.9 -Only for users with developer access to the group. +Only for users with Developer access to the group. ``` POST /projects/:id/milestones/:milestone_id/promote diff --git a/doc/api/packages.md b/doc/api/packages.md index 5b490b872da1268be2ce3037800fb23cccb64ba5..cadd5f0dc7500135b817bfdf33926322fa42d2d1 100644 --- a/doc/api/packages.md +++ b/doc/api/packages.md @@ -31,13 +31,15 @@ Example response: "id": 1, "name": "com/mycompany/my-app", "version": "1.0-SNAPSHOT", - "package_type": "maven" + "package_type": "maven", + "created_at": "2019-11-27T03:37:38.711Z" }, { "id": 2, "name": "@foo/bar", "version": "1.0.3", - "package_type": "npm" + "package_type": "npm", + "created_at": "2019-11-27T03:37:38.711Z" } ] ``` @@ -76,6 +78,18 @@ Example response: "_links": { "web_path": "/namespace1/project1/-/packages/1", "delete_api_path": "/namespace1/project1/-/packages/1" + }, + "created_at": "2019-11-27T03:37:38.711Z", + "build_info": { + "pipeline": { + "id": 123, + "status": "pending", + "ref": "new-pipeline", + "sha": "a91957a858320c0e17f3a0eca7cfacbff50ea29a", + "web_url": "https://example.com/foo/bar/pipelines/47", + "created_at": "2016-08-11T11:28:34.085Z", + "updated_at": "2016-08-11T11:32:35.169Z", + } } }, { @@ -86,6 +100,18 @@ Example response: "_links": { "web_path": "/namespace1/project1/-/packages/1", "delete_api_path": "/namespace1/project1/-/packages/1" + }, + "created_at": "2019-11-27T03:37:38.711Z", + "build_info": { + "pipeline": { + "id": 123, + "status": "pending", + "ref": "new-pipeline", + "sha": "a91957a858320c0e17f3a0eca7cfacbff50ea29a", + "web_url": "https://example.com/foo/bar/pipelines/47", + "created_at": "2016-08-11T11:28:34.085Z", + "updated_at": "2016-08-11T11:32:35.169Z", + } } } ] @@ -128,6 +154,18 @@ Example response: "_links": { "web_path": "/namespace1/project1/-/packages/1", "delete_api_path": "/namespace1/project1/-/packages/1" + }, + "created_at": "2019-11-27T03:37:38.711Z", + "build_info": { + "pipeline": { + "id": 123, + "status": "pending", + "ref": "new-pipeline", + "sha": "a91957a858320c0e17f3a0eca7cfacbff50ea29a", + "web_url": "https://example.com/foo/bar/pipelines/47", + "created_at": "2016-08-11T11:28:34.085Z", + "updated_at": "2016-08-11T11:32:35.169Z", + } } } ``` diff --git a/doc/api/pipelines.md b/doc/api/pipelines.md index e1b2c12dd00ef440719a1550a25fe552ca76a0ca..4bf447230654c2fc75eea5ef34814f5cb6fa241b 100644 --- a/doc/api/pipelines.md +++ b/doc/api/pipelines.md @@ -14,7 +14,7 @@ GET /projects/:id/pipelines | `scope` | string | no | The scope of pipelines, one of: `running`, `pending`, `finished`, `branches`, `tags` | | `status` | string | no | The status of pipelines, one of: `running`, `pending`, `success`, `failed`, `canceled`, `skipped` | | `ref` | string | no | The ref of pipelines | -| `sha` | string | no | The sha or pipelines | +| `sha` | string | no | The sha of pipelines | | `yaml_errors`| boolean | no | Returns pipelines with invalid configurations | | `name`| string | no | The name of the user who triggered pipelines | | `username`| string | no | The username of the user who triggered pipelines | diff --git a/doc/api/project_level_variables.md b/doc/api/project_level_variables.md index 591911bb8ecd101bbaf3c8057ced2818ba27d055..d4bda992f7c80e0aff841c72ab003af7a56a3eb6 100644 --- a/doc/api/project_level_variables.md +++ b/doc/api/project_level_variables.md @@ -86,7 +86,6 @@ curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" "https://gitla "value": "new value", "protected": false, "variable_type": "env_var", - "protected": false, "masked": false, "environment_scope": "*" } diff --git a/doc/api/projects.md b/doc/api/projects.md index 209d41d62cd9a3df1957ebe8d60d516444bbfbe4..8cfba68aceebe2dc1fd0390f22f3c78bf980cbc7 100644 --- a/doc/api/projects.md +++ b/doc/api/projects.md @@ -156,6 +156,8 @@ When the user is authenticated and `simple` is not set this returns something li "remove_source_branch_after_merge": false, "request_access_enabled": false, "merge_method": "merge", + "autoclose_referenced_issues": true, + "suggestion_commit_message": null, "statistics": { "commit_count": 37, "storage_size": 1038090, @@ -254,6 +256,8 @@ When the user is authenticated and `simple` is not set this returns something li "packages_enabled": true, "service_desk_enabled": false, "service_desk_address": null, + "autoclose_referenced_issues": true, + "suggestion_commit_message": null, "statistics": { "commit_count": 12, "storage_size": 2066080, @@ -385,6 +389,8 @@ This endpoint supports [keyset pagination](README.md#keyset-based-pagination) fo "remove_source_branch_after_merge": false, "request_access_enabled": false, "merge_method": "merge", + "autoclose_referenced_issues": true, + "suggestion_commit_message": null, "statistics": { "commit_count": 37, "storage_size": 1038090, @@ -483,6 +489,8 @@ This endpoint supports [keyset pagination](README.md#keyset-based-pagination) fo "packages_enabled": true, "service_desk_enabled": false, "service_desk_address": null, + "autoclose_referenced_issues": true, + "suggestion_commit_message": null, "statistics": { "commit_count": 12, "storage_size": 2066080, @@ -593,6 +601,8 @@ Example response: "remove_source_branch_after_merge": false, "request_access_enabled": false, "merge_method": "merge", + "autoclose_referenced_issues": true, + "suggestion_commit_message": null, "statistics": { "commit_count": 37, "storage_size": 1038090, @@ -688,6 +698,8 @@ Example response: "packages_enabled": true, "service_desk_enabled": false, "service_desk_address": null, + "autoclose_referenced_issues": true, + "suggestion_commit_message": null, "statistics": { "commit_count": 12, "storage_size": 2066080, @@ -755,6 +767,14 @@ GET /projects/:id "snippets_enabled": false, "resolve_outdated_diff_discussions": false, "container_registry_enabled": false, + "container_expiration_policy": { + "cadence": "7d", + "enabled": false, + "keep_n": null, + "older_than": null, + "name_regex": null, + "next_run_at": "2020-01-07T21:42:58.658Z" + }, "created_at": "2013-09-30T13:46:02Z", "last_activity_at": "2013-09-30T13:46:02Z", "creator_id": 3, @@ -829,6 +849,8 @@ GET /projects/:id "packages_enabled": true, "service_desk_enabled": false, "service_desk_address": null, + "autoclose_referenced_issues": true, + "suggestion_commit_message": null, "statistics": { "commit_count": 37, "storage_size": 1038090, @@ -979,6 +1001,7 @@ POST /projects | `snippets_access_level` | string | no | One of `disabled`, `private` or `enabled` | | `resolve_outdated_diff_discussions` | boolean | no | Automatically resolve merge request diffs discussions on lines changed with a push | | `container_registry_enabled` | boolean | no | Enable container registry for this project | +| `container_expiration_policy_attributes` | hash | no | Update the container expiration policy for this project. Accepts: `cadence` (string), `keep_n` (string), `older_than` (string), `name_regex` (string), `enabled` (boolean) | | `shared_runners_enabled` | boolean | no | Enable shared runners for this project | | `visibility` | string | no | See [project visibility level](#project-visibility-level) | | `import_url` | string | no | URL to import repository from | @@ -986,6 +1009,7 @@ POST /projects | `only_allow_merge_if_pipeline_succeeds` | boolean | no | Set whether merge requests can only be merged with successful jobs | | `only_allow_merge_if_all_discussions_are_resolved` | boolean | no | Set whether merge requests can only be merged when all the discussions are resolved | | `merge_method` | string | no | Set the [merge method](#project-merge-method) used | +| `autoclose_referenced_issues` | boolean | no | Set whether auto-closing referenced issues on default branch | | `remove_source_branch_after_merge` | boolean | no | Enable `Delete source branch` option by default for all new merge requests | | `lfs_enabled` | boolean | no | Enable LFS | | `request_access_enabled` | boolean | no | Allow users to request member access | @@ -1050,6 +1074,8 @@ POST /projects/user/:user_id | `only_allow_merge_if_pipeline_succeeds` | boolean | no | Set whether merge requests can only be merged with successful jobs | | `only_allow_merge_if_all_discussions_are_resolved` | boolean | no | Set whether merge requests can only be merged when all the discussions are resolved | | `merge_method` | string | no | Set the [merge method](#project-merge-method) used | +| `autoclose_referenced_issues` | boolean | no | Set whether auto-closing referenced issues on default branch | +| `suggestion_commit_message` | string | no | The commit message used to apply merge request suggestions | | `remove_source_branch_after_merge` | boolean | no | Enable `Delete source branch` option by default for all new merge requests | | `lfs_enabled` | boolean | no | Enable LFS | | `request_access_enabled` | boolean | no | Allow users to request member access | @@ -1106,6 +1132,7 @@ PUT /projects/:id | `snippets_access_level` | string | no | One of `disabled`, `private` or `enabled` | | `resolve_outdated_diff_discussions` | boolean | no | Automatically resolve merge request diffs discussions on lines changed with a push | | `container_registry_enabled` | boolean | no | Enable container registry for this project | +| `container_expiration_policy_attributes` | hash | no | Update the container expiration policy for this project. Accepts: `cadence` (string), `keep_n` (string), `older_than` (string), `name_regex` (string), `enabled` (boolean) | | `shared_runners_enabled` | boolean | no | Enable shared runners for this project | | `visibility` | string | no | See [project visibility level](#project-visibility-level) | | `import_url` | string | no | URL to import repository from | @@ -1113,6 +1140,8 @@ PUT /projects/:id | `only_allow_merge_if_pipeline_succeeds` | boolean | no | Set whether merge requests can only be merged with successful jobs | | `only_allow_merge_if_all_discussions_are_resolved` | boolean | no | Set whether merge requests can only be merged when all the discussions are resolved | | `merge_method` | string | no | Set the [merge method](#project-merge-method) used | +| `autoclose_referenced_issues` | boolean | no | Set whether auto-closing referenced issues on default branch | +| `suggestion_commit_message` | string | no | The commit message used to apply merge request suggestions | | `remove_source_branch_after_merge` | boolean | no | Enable `Delete source branch` option by default for all new merge requests | | `lfs_enabled` | boolean | no | Enable LFS | | `request_access_enabled` | boolean | no | Allow users to request member access | @@ -1244,6 +1273,8 @@ Example responses: "remove_source_branch_after_merge": false, "request_access_enabled": false, "merge_method": "merge", + "autoclose_referenced_issues": true, + "suggestion_commit_message": null, "_links": { "self": "http://example.com/api/v4/projects", "issues": "http://example.com/api/v4/projects/1/issues", @@ -1332,6 +1363,8 @@ Example response: "remove_source_branch_after_merge": false, "request_access_enabled": false, "merge_method": "merge", + "autoclose_referenced_issues": true, + "suggestion_commit_message": null, "_links": { "self": "http://example.com/api/v4/projects", "issues": "http://example.com/api/v4/projects/1/issues", @@ -1419,6 +1452,8 @@ Example response: "remove_source_branch_after_merge": false, "request_access_enabled": false, "merge_method": "merge", + "autoclose_referenced_issues": true, + "suggestion_commit_message": null, "_links": { "self": "http://example.com/api/v4/projects", "issues": "http://example.com/api/v4/projects/1/issues", @@ -1593,6 +1628,8 @@ Example response: "remove_source_branch_after_merge": false, "request_access_enabled": false, "merge_method": "merge", + "autoclose_referenced_issues": true, + "suggestion_commit_message": null, "_links": { "self": "http://example.com/api/v4/projects", "issues": "http://example.com/api/v4/projects/1/issues", @@ -1699,6 +1736,8 @@ Example response: "remove_source_branch_after_merge": false, "request_access_enabled": false, "merge_method": "merge", + "autoclose_referenced_issues": true, + "suggestion_commit_message": null, "_links": { "self": "http://example.com/api/v4/projects", "issues": "http://example.com/api/v4/projects/1/issues", diff --git a/doc/api/services.md b/doc/api/services.md index 02a31ba9d38ed9dc22deaeaec190e2032ec0dce9..521233206513fe53f75bf592aad7bd3500cffd94 100644 --- a/doc/api/services.md +++ b/doc/api/services.md @@ -2,6 +2,63 @@ >**Note:** This API requires an access token with Maintainer or Owner permissions +## List all active services + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/merge_requests/21330) in GitLab 12.7. + +Get a list of all active project services. + +``` +GET /projects/:id/services +``` + +Example response: + +```json +[ + { + "id": 75, + "title": "Jenkins CI", + "slug": "jenkins", + "created_at": "2019-11-20T11:20:25.297Z", + "updated_at": "2019-11-20T12:24:37.498Z", + "active": true, + "commit_events": true, + "push_events": true, + "issues_events": true, + "confidential_issues_events": true, + "merge_requests_events": true, + "tag_push_events": false, + "note_events": true, + "confidential_note_events": true, + "pipeline_events": true, + "wiki_page_events": true, + "job_events": true, + "comment_on_event_enabled": true + } + { + "id": 76, + "title": "Alerts endpoint", + "slug": "alerts", + "created_at": "2019-11-20T11:20:25.297Z", + "updated_at": "2019-11-20T12:24:37.498Z", + "active": true, + "commit_events": true, + "push_events": true, + "issues_events": true, + "confidential_issues_events": true, + "merge_requests_events": true, + "tag_push_events": true, + "note_events": true, + "confidential_note_events": true, + "pipeline_events": true, + "wiki_page_events": true, + "job_events": true, + "comment_on_event_enabled": true + } +] +``` + ## Asana Asana - Teamwork without email @@ -373,6 +430,7 @@ Parameters: | `send_from_committer_email` | boolean | false | Send from committer | | `push_events` | boolean | false | Enable notifications for push events | | `tag_push_events` | boolean | false | Enable notifications for tag push events | +| `branches_to_be_notified` | string | all | Branches to send notifications for. Valid options are "all", "default", "protected", and "default_and_protected". Notifications will be always fired for tag pushes. | ### Delete Emails on push service @@ -669,6 +727,7 @@ Parameters: | `jira_issue_transition_id` | string | no | The ID of a transition that moves issues to a closed state. You can find this number under the Jira workflow administration (**Administration > Issues > Workflows**) by selecting **View** under **Operations** of the desired workflow of your project. The ID of each state can be found inside the parenthesis of each transition name under the **Transitions (id)** column. By default, this ID is set to `2`. | | `commit_events` | boolean | false | Enable notifications for commit events | | `merge_requests_events` | boolean | false | Enable notifications for merge request events | +| `comment_on_event_enabled` | boolean | false | Enable comments inside Jira issues on each GitLab event (commit / merge request) | ### Delete Jira service @@ -696,6 +755,7 @@ Example response: { "id": 4, "title": "Slack slash commands", + "slug": "slack-slash-commands", "created_at": "2017-06-27T05:51:39-07:00", "updated_at": "2017-06-27T05:51:39-07:00", "active": true, @@ -707,6 +767,7 @@ Example response: "note_events": true, "job_events": true, "pipeline_events": true, + "comment_on_event_enabled": false, "properties": { "token": "<your_access_token>" } diff --git a/doc/api/settings.md b/doc/api/settings.md index fa0efcaa5f00e63752c9f2af7bd9b605647ca44c..316e5bb0109f714dd15fb305b9d1f1837f233c4e 100644 --- a/doc/api/settings.md +++ b/doc/api/settings.md @@ -180,7 +180,7 @@ are listed in the descriptions of the relevant settings. | Attribute | Type | Required | Description | | --------- | ---- | :------: | ----------- | -| `admin_notification_email` | string | no | Abuse reports will be sent to this address if it is set. Abuse reports are always available in the admin area. | +| `admin_notification_email` | string | no | Abuse reports will be sent to this address if it is set. Abuse reports are always available in the Admin Area. | | `after_sign_out_path` | string | no | Where to redirect users after logout. | | `after_sign_up_text` | string | no | Text shown to the user after signing up | | `akismet_api_key` | string | required by: `akismet_enabled` | API key for Akismet spam protection. | diff --git a/doc/api/system_hooks.md b/doc/api/system_hooks.md index 1b8c23b8e2d07f101cb888852ed138f2307b0b87..b09b11dfd2a232550a074474e66a04e2ddc9eb3e 100644 --- a/doc/api/system_hooks.md +++ b/doc/api/system_hooks.md @@ -3,7 +3,7 @@ All methods require administrator authorization. The URL endpoint of the system hooks can also be configured using the UI in -the admin area under **Hooks** (`/admin/hooks`). +the **Admin Area > System Hooks** (`/admin/hooks`). Read more about [system hooks](../system_hooks/system_hooks.md). diff --git a/doc/api/wikis.md b/doc/api/wikis.md index 570fb2168b20d8c9a8c202c95b178df09b73b10c..035a89d80a5618d5314254328c0a7ef4e6ff67c6 100644 --- a/doc/api/wikis.md +++ b/doc/api/wikis.md @@ -86,7 +86,7 @@ POST /projects/:id/wikis | `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) | | `content` | string | yes | The content of the wiki page | | `title` | string | yes | The title of the wiki page | -| `format` | string | no | The format of the wiki page. Available formats are: `markdown` (default), `rdoc`, and `asciidoc` | +| `format` | string | no | The format of the wiki page. Available formats are: `markdown` (default), `rdoc`, `asciidoc` and `org` | ```bash curl --data "format=rdoc&title=Hello&content=Hello world" --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/1/wikis" @@ -116,7 +116,7 @@ PUT /projects/:id/wikis/:slug | `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) | | `content` | string | yes if `title` is not provided | The content of the wiki page | | `title` | string | yes if `content` is not provided | The title of the wiki page | -| `format` | string | no | The format of the wiki page. Available formats are: `markdown` (default), `rdoc`, and `asciidoc` | +| `format` | string | no | The format of the wiki page. Available formats are: `markdown` (default), `rdoc`, `asciidoc` and `org` | | `slug` | string | yes | The slug (a unique string) of the wiki page | ```bash diff --git a/doc/ci/README.md b/doc/ci/README.md index d1cf7e63c6319d2f0e4abcdde2c4fdc9be349d54..8a33298ea63cdb3e0abcf7600dec5941371b47f5 100644 --- a/doc/ci/README.md +++ b/doc/ci/README.md @@ -127,6 +127,7 @@ Its feature set is listed on the table below according to DevOps stages. | [GitLab Pages](../user/project/pages/index.md) | Deploy static websites. | | [GitLab Releases](../user/project/releases/index.md) | Add release notes to Git tags. | | [Review Apps](review_apps/index.md) | Configure GitLab CI/CD to preview code changes. | +| [Cloud deployment](cloud_deployment/index.md) | Deploy your application to a main cloud provider. | |---+---| | **Secure** || | [Container Scanning](../user/application_security/container_scanning/index.md) **(ULTIMATE)** | Check your Docker containers for known vulnerabilities.| diff --git a/doc/ci/caching/index.md b/doc/ci/caching/index.md index b6518c87e13bc051deff93cb97e7d0ffe89c648d..a85c096db705497b25921d3f7a6bd86272464485 100644 --- a/doc/ci/caching/index.md +++ b/doc/ci/caching/index.md @@ -59,7 +59,7 @@ Caches: - Are stored where the Runner is installed **and** uploaded to S3 if [distributed cache is enabled](https://docs.gitlab.com/runner/configuration/autoscale.html#distributed-runners-caching). - If defined per job, are used: - By the same job in a subsequent pipeline. - - By subsequent jobs in the same pipeline, if the they have identical dependencies. + - By subsequent jobs in the same pipeline, if they have identical dependencies. Artifacts: diff --git a/doc/ci/chatops/README.md b/doc/ci/chatops/README.md index d9236b47a9ae0d4cb5b072d06ae6cb0deaff0588..ec3d13e7500c78f377f76648dfba275f68011848 100644 --- a/doc/ci/chatops/README.md +++ b/doc/ci/chatops/README.md @@ -61,7 +61,7 @@ ls: ## GitLab ChatOps Examples The GitLab.com team created a repository of [common ChatOps scripts they use to interact with our Production instance of GitLab](https://gitlab.com/gitlab-com/chatops). They are likely useful -to other adminstrators of GitLab instances and can serve as inspiration for ChatOps scripts you can write to interact with your own applications. +to other administrators of GitLab instances and can serve as inspiration for ChatOps scripts you can write to interact with your own applications. ## GitLab ChatOps icon diff --git a/doc/ci/cloud_deployment/index.md b/doc/ci/cloud_deployment/index.md new file mode 100644 index 0000000000000000000000000000000000000000..07ffe5439e341da7ab2a84074837bdd9f9f7487b --- /dev/null +++ b/doc/ci/cloud_deployment/index.md @@ -0,0 +1,63 @@ +--- +type: howto +--- + +# Cloud deployment + +Interacting with a major cloud provider such as Amazon AWS may have become a much needed task that's +part of your delivery process. GitLab is making this process less painful by providing Docker images +that come with the needed libraries and tools pre-installed. +By referencing them in your CI/CD pipeline, you'll be able to interact with your chosen +cloud provider more easily. + +## AWS + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/31167) in GitLab 12.6. + +GitLab's AWS Docker image provides the [AWS Command Line Interface](https://aws.amazon.com/cli/), +which enables you to run `aws` commands. As part of your deployment strategy, you can run `aws` commands directly from +`.gitlab-ci.yml` by specifying [GitLab's AWS Docker image](https://gitlab.com/gitlab-org/cloud-deploy). + +Some credentials are required to be able to run `aws` commands: + +1. Sign up for [an AWS account](https://docs.aws.amazon.com/IAM/latest/UserGuide/getting-set-up.html) if you don't have one yet. +1. Log in onto the console and create [a new IAM user](https://console.aws.amazon.com/iam/home#/home). +1. Select your newly created user to access its details. Navigate to **Security credentials > Create a new access key**. + + NOTE: **Note:** + A new **Access key ID** and **Secret access key** pair will be generated. Please take a note of them right away. + +1. In your GitLab project, go to **Settings > CI / CD**. Set the Access key ID and Secret access key as [environment variables](../variables/README.md#gitlab-cicd-environment-variables), using the following variable names: + + | Env. variable name | Value | + |:------------------------|:-------------------------| + | `AWS_ACCESS_KEY_ID` | Your "Access key ID" | + | `AWS_SECRET_ACCESS_KEY` | Your "Secret access key" | + +1. You can now use `aws` commands in the `.gitlab-ci.yml` file of this project: + + ```yml + deploy: + stage: deploy + image: registry.gitlab.com/gitlab-org/cloud-deploy:latest # see the note below + script: + - aws s3 ... + - aws create-deployment ... + ``` + + NOTE: **Note:** + Please note that the image used in the example above + (`registry.gitlab.com/gitlab-org/cloud-deploy:latest`) is hosted on the [GitLab + Container Registry](../../user/packages/container_registry/index.md) and is + ready to use. Alternatively, replace the image with another one hosted on [AWS ECR](#aws-ecr). + +### AWS ECR + +Instead of referencing an image hosted on the GitLab Registry, you are free to +reference any other image hosted on any third-party registry, such as +[Amazon Elastic Container Registry (ECR)](https://aws.amazon.com/ecr). + +To do so, please make sure to [push your image into your ECR +repository](https://docs.aws.amazon.com/AmazonECR/latest/userguide/docker-push-ecr-image.html) +before referencing it in your `.gitlab-ci.yml` file and replace the `image` +path to point to your ECR. diff --git a/doc/ci/docker/using_docker_images.md b/doc/ci/docker/using_docker_images.md index e58fe5e4604c7d27f8477605505171c2329a4d24..8c6069bd939eafead8ee147011238f2deab4721e 100644 --- a/doc/ci/docker/using_docker_images.md +++ b/doc/ci/docker/using_docker_images.md @@ -32,14 +32,14 @@ A one-line example can be seen below: sudo gitlab-runner register \ --url "https://gitlab.example.com/" \ --registration-token "PROJECT_REGISTRATION_TOKEN" \ - --description "docker-ruby-2.1" \ + --description "docker-ruby:2.6" \ --executor "docker" \ - --docker-image ruby:2.1 \ + --docker-image ruby:2.6 \ --docker-services postgres:latest \ --docker-services mysql:latest ``` -The registered runner will use the `ruby:2.1` Docker image and will run two +The registered runner will use the `ruby:2.6` Docker image and will run two services, `postgres:latest` and `mysql:latest`, both of which will be accessible during the build process. @@ -194,7 +194,7 @@ services that you want to use during build time: ```yaml default: - image: ruby:2.2 + image: ruby:2.6 services: - postgres:9.3 @@ -214,15 +214,15 @@ default: before_script: - bundle install -test:2.1: - image: ruby:2.1 +test:2.6: + image: ruby:2.6 services: - postgres:9.3 script: - bundle exec rake spec -test:2.2: - image: ruby:2.2 +test:2.7: + image: ruby:2.7 services: - postgres:9.4 script: @@ -235,7 +235,7 @@ for `image` and `services`: ```yaml default: image: - name: ruby:2.2 + name: ruby:2.6 entrypoint: ["/bin/bash"] services: @@ -277,7 +277,7 @@ services: command: ["postgres"] image: - name: ruby:2.2 + name: ruby:2.6 entrypoint: ["/bin/bash"] before_script: @@ -513,7 +513,7 @@ To define which should be used, the GitLab Runner process reads the configuratio NOTE: **Note:** GitLab Runner reads this configuration **only** from `config.toml` and ignores it if -it's provided as an environment variable. This is because GitLab Runnner uses **only** +it's provided as an environment variable. This is because GitLab Runner uses **only** `config.toml` configuration and doesn't interpolate **ANY** environment variables at runtime. @@ -773,7 +773,7 @@ time. 1. Create any service container: `mysql`, `postgresql`, `mongodb`, `redis`. 1. Create cache container to store all volumes as defined in `config.toml` and - `Dockerfile` of build image (`ruby:2.1` as in above example). + `Dockerfile` of build image (`ruby:2.6` as in above example). 1. Create build container and link any service container to build container. 1. Start build container and send job script to the container. 1. Run job script. @@ -818,11 +818,11 @@ Finally, create a build container by executing the `build_script` file we created earlier: ```sh -docker run --name build -i --link=service-mysql:mysql --link=service-postgres:postgres ruby:2.1 /bin/bash < build_script +docker run --name build -i --link=service-mysql:mysql --link=service-postgres:postgres ruby:2.6 /bin/bash < build_script ``` The above command will create a container named `build` that is spawned from -the `ruby:2.1` image and has two services linked to it. The `build_script` is +the `ruby:2.6` image and has two services linked to it. The `build_script` is piped using STDIN to the bash interpreter which in turn executes the `build_script` in the `build` container. diff --git a/doc/ci/enable_or_disable_ci.md b/doc/ci/enable_or_disable_ci.md index ff104d4e1775da313c3236f7d54afe0ad1e8ec65..7c7640e23c3e71d0961d4cff0406a007d6ea2928 100644 --- a/doc/ci/enable_or_disable_ci.md +++ b/doc/ci/enable_or_disable_ci.md @@ -38,18 +38,16 @@ To enable or disable GitLab CI/CD Pipelines in your project: 1. Navigate to **Settings > General > Visibility, project features, permissions**. 1. Expand the **Repository** section -1. Enable or disable the **Pipelines** checkbox as required. +1. Enable or disable the **Pipelines** toggle as required. **Project visibility** will also affect pipeline visibility. If set to: - **Private**: Only project members can access pipelines. - **Internal** or **Public**: Pipelines can be set to either **Only Project Members** - or **Everyone With Access** via the drop-down box. + or **Everyone With Access** via the dropdown box. Press **Save changes** for the settings to take effect. - - ## Site-wide admin setting You can disable GitLab CI/CD site-wide, by modifying the settings in `gitlab.yml` diff --git a/doc/ci/environments.md b/doc/ci/environments.md index 245a4d20e2db9022b51d147cdacaf37757f11f0c..55e93e19f667c007922b8e05885d94c6c1da8787 100644 --- a/doc/ci/environments.md +++ b/doc/ci/environments.md @@ -232,6 +232,11 @@ declaring their names dynamically in `.gitlab-ci.yml`. Dynamic environments are a fundamental part of [Review apps](review_apps/index.md). +### Configuring incremental rollouts + +Learn how to release production changes to only a portion of your Kubernetes pods with +[incremental rollouts](environments/incremental_rollouts.md). + #### Allowed variables The `name` and `url` parameters for dynamic environments can use most available CI/CD variables, diff --git a/doc/ci/environments/img/incremental_rollouts_play_v12_7.png b/doc/ci/environments/img/incremental_rollouts_play_v12_7.png new file mode 100644 index 0000000000000000000000000000000000000000..314c4a07af0d7e4c6ca2db5394a90682c1e37da1 Binary files /dev/null and b/doc/ci/environments/img/incremental_rollouts_play_v12_7.png differ diff --git a/doc/ci/environments/img/timed_rollout_v12_7.png b/doc/ci/environments/img/timed_rollout_v12_7.png new file mode 100644 index 0000000000000000000000000000000000000000..6b83bfc574ee65eb95535320713d7e0a0ab2899d Binary files /dev/null and b/doc/ci/environments/img/timed_rollout_v12_7.png differ diff --git a/doc/ci/environments/incremental_rollouts.md b/doc/ci/environments/incremental_rollouts.md new file mode 100644 index 0000000000000000000000000000000000000000..0fa0af6a9fbcf0606df6d01a3c7911ad91b5766d --- /dev/null +++ b/doc/ci/environments/incremental_rollouts.md @@ -0,0 +1,113 @@ +--- +type: concepts, howto +--- + +# Incremental Rollouts with GitLab CI/CD + +When rolling out changes to your application, it is possible to release production changes +to only a portion of your Kubernetes pods as a risk mitigation strategy. By releasing +production changes gradually, error rates or performance degradation can be monitored, and +if there are no problems, all pods can be updated. + +GitLab supports both manually triggered and timed rollouts to a Kubernetes production system +using Incremental Rollouts. When using Manual Rollouts, the release of each tranche +of pods is manually triggered, while in Timed Rollouts, the release is performed in +tranches after a default pause of 5 minutes. +Timed rollouts can also be manually triggered before the pause period has expired. + +Manual and Timed rollouts are included automatically in projects controlled by +[AutoDevOps](../../topics/autodevops/index.md), but they are also configurable through +GitLab CI/CD in the `.gitlab-ci.yml` configuration file. + +Manually triggered rollouts can be implemented with your [Continuously Delivery](../introduction/index.md#continuous-delivery) +methodology, while timed rollouts do not require intervention and can be part of your +[Continuously Deployment](../introduction/index.md#continuous-deployment) strategy. +You can also combine both of them in a way that the app is deployed automatically +unless you eventually intervene manually if necessary. + +We created sample applications to demonstrate the three options, which you can +use as examples to build your own: + +- [Manual incremental rollouts](https://gitlab.com/gl-release/incremental-rollout-example/blob/master/.gitlab-ci.yml) +- [Timed incremental rollouts](https://gitlab.com/gl-release/timed-rollout-example/blob/master/.gitlab-ci.yml) +- [Both manual and timed rollouts](https://gitlab.com/gl-release/incremental-timed-rollout-example/blob/master/.gitlab-ci.yml) + +## Manual Rollouts + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/5415) in GitLab 10.8. + +It is possible to configure GitLab to do incremental rollouts manually through `.gitlab-ci.yml`. Manual configuration +allows more control over the this feature. The steps in an incremental rollout depend on the +number of pods that are defined for the deployment, which are configured when the Kubernetes +cluster is created. + +For example, if your application has 10 pods and a 10% rollout job is run, the new instance of the +application will be deployed to a single pod while the remaining 9 will present the previous instance. + +First we [define the template as manual](https://gitlab.com/gl-release/incremental-rollout-example/blob/master/.gitlab-ci.yml#L100-103): + +```yml +.manual_rollout_template: &manual_rollout_template + <<: *rollout_template + stage: production + when: manual +``` + +Then we [define the rollout amount for each step](https://gitlab.com/gl-release/incremental-rollout-example/blob/master/.gitlab-ci.yml#L152-155): + +```yml +rollout 10%: + <<: *manual_rollout_template + variables: + ROLLOUT_PERCENTAGE: 10 +``` + +When the jobs are built, a **play** button will appear next to the job's name. Click the **play** button +to release each stage of pods. You can also rollback by running a lower percentage job. Once 100% +is reached, you cannot roll back using this method. It is still possible to roll back by redeploying +the old version using the **Rollback** button on the environment page. + + + +A [deployable application](https://gitlab.com/gl-release/incremental-rollout-example) is +available, demonstrating manually triggered incremental rollouts. + +## Timed Rollouts + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/7545) in GitLab 11.4. + +Timed rollouts behave in the same way as manual rollouts, except that each job is defined with a delay +in minutes before it will deploy. Clicking on the job will reveal the countdown. + + + +It is possible to combine this functionality with manual incremental rollouts so that the job will +countdown and then deploy. + +First we [define the template as timed](https://gitlab.com/gl-release/timed-rollout-example/blob/master/.gitlab-ci.yml#L86-89): + +```yml +.timed_rollout_template: &timed_rollout_template + <<: *rollout_template + when: delayed + start_in: 1 minutes +``` + +We can define the delay period using the `start_in` key: + +```yml +start_in: 1 minutes +``` + +Then we [define the rollout amount for each step](https://gitlab.com/gl-release/timed-rollout-example/blob/master/.gitlab-ci.yml#L97-101): + +```yml +timed rollout 30%: + <<: *timed_rollout_template + stage: timed rollout 30% + variables: + ROLLOUT_PERCENTAGE: 30 +``` + +A [deployable application](https://gitlab.com/gl-release/timed-rollout-example) is +available, [demonstrating configuration of timed rollouts](https://gitlab.com/gl-release/timed-rollout-example/blob/master/.gitlab-ci.yml#L86-95). diff --git a/doc/ci/examples/deployment/README.md b/doc/ci/examples/deployment/README.md index 7af797f1851c3fc19dcb89dc6b4471d6c5b2081c..dca11c40524220964dc010c82a227539ced69cda 100644 --- a/doc/ci/examples/deployment/README.md +++ b/doc/ci/examples/deployment/README.md @@ -4,7 +4,7 @@ type: tutorial # Using Dpl as deployment tool -[Dpl](https://github.com/travis-ci/dpl) (prouncounced like the letters D-P-L) is a deploy tool made for +[Dpl](https://github.com/travis-ci/dpl) (pronounced like the letters D-P-L) is a deploy tool made for continuous deployment that's developed and used by Travis CI, but can also be used with GitLab CI. diff --git a/doc/ci/examples/devops_and_game_dev_with_gitlab_ci_cd/index.md b/doc/ci/examples/devops_and_game_dev_with_gitlab_ci_cd/index.md index ffcc81953950d014520ce7e8e77d2f2f1423bff1..788d57b81f8e47a876033febb681ba516f5c383f 100644 --- a/doc/ci/examples/devops_and_game_dev_with_gitlab_ci_cd/index.md +++ b/doc/ci/examples/devops_and_game_dev_with_gitlab_ci_cd/index.md @@ -26,7 +26,7 @@ Creating a strong CI/CD pipeline at the beginning of developing another game, [D was essential for the fast pace the team worked at. This tutorial will build upon my [previous introductory article](https://ryanhallcs.wordpress.com/2017/03/15/devops-and-game-dev/) and go through the following steps: -1. Using code from the previous article to start with a barebones [Phaser](https://phaser.io) game built by a gulp file +1. Using code from the previous article to start with a bare-bones [Phaser](https://phaser.io) game built by a gulp file 1. Adding and running unit tests 1. Creating a `Weapon` class that can be triggered to spawn a `Bullet` in a given direction 1. Adding a `Player` class that uses this weapon and moves around the screen diff --git a/doc/ci/examples/test-and-deploy-ruby-application-to-heroku.md b/doc/ci/examples/test-and-deploy-ruby-application-to-heroku.md index 9a4fbfcce6d40ab1d894dd78d780567da6e74358..66246a0fda29781531e83b8a9e4e7f153fe3e1a6 100644 --- a/doc/ci/examples/test-and-deploy-ruby-application-to-heroku.md +++ b/doc/ci/examples/test-and-deploy-ruby-application-to-heroku.md @@ -71,12 +71,12 @@ gitlab-runner register \ --non-interactive \ --url "https://gitlab.com/" \ --registration-token "PROJECT_REGISTRATION_TOKEN" \ - --description "ruby-2.2" \ + --description "ruby:2.6" \ --executor "docker" \ - --docker-image ruby:2.2 \ + --docker-image ruby:2.6 \ --docker-postgres latest ``` -With the command above, you create a Runner that uses the [ruby:2.2](https://hub.docker.com/_/ruby) image and uses a [postgres](https://hub.docker.com/_/postgres) database. +With the command above, you create a Runner that uses the [ruby:2.6](https://hub.docker.com/_/ruby) image and uses a [postgres](https://hub.docker.com/_/postgres) database. To access the PostgreSQL database, connect to `host: postgres` as user `postgres` with no password. diff --git a/doc/ci/examples/test_phoenix_app_with_gitlab_ci_cd/img/select-template.png b/doc/ci/examples/test_phoenix_app_with_gitlab_ci_cd/img/select-template.png deleted file mode 100644 index 727995f463c8c37b206eba2df08925a09501d1dd..0000000000000000000000000000000000000000 Binary files a/doc/ci/examples/test_phoenix_app_with_gitlab_ci_cd/img/select-template.png and /dev/null differ diff --git a/doc/ci/examples/test_phoenix_app_with_gitlab_ci_cd/img/select_template_v12_6.png b/doc/ci/examples/test_phoenix_app_with_gitlab_ci_cd/img/select_template_v12_6.png new file mode 100644 index 0000000000000000000000000000000000000000..97887db44861195fa62735085a4ef0d1e2894aa5 Binary files /dev/null and b/doc/ci/examples/test_phoenix_app_with_gitlab_ci_cd/img/select_template_v12_6.png differ diff --git a/doc/ci/examples/test_phoenix_app_with_gitlab_ci_cd/img/set_up_ci_v12_6.png b/doc/ci/examples/test_phoenix_app_with_gitlab_ci_cd/img/set_up_ci_v12_6.png new file mode 100644 index 0000000000000000000000000000000000000000..85fb58d4458d84feec88721b71ea80880f3f53c9 Binary files /dev/null and b/doc/ci/examples/test_phoenix_app_with_gitlab_ci_cd/img/set_up_ci_v12_6.png differ diff --git a/doc/ci/examples/test_phoenix_app_with_gitlab_ci_cd/img/setup-ci.png b/doc/ci/examples/test_phoenix_app_with_gitlab_ci_cd/img/setup-ci.png deleted file mode 100644 index 50c6ca593c1d63180240950fbd439782a7efa6be..0000000000000000000000000000000000000000 Binary files a/doc/ci/examples/test_phoenix_app_with_gitlab_ci_cd/img/setup-ci.png and /dev/null differ diff --git a/doc/ci/examples/test_phoenix_app_with_gitlab_ci_cd/index.md b/doc/ci/examples/test_phoenix_app_with_gitlab_ci_cd/index.md index 541578db294413fa2f166a7284b472e4e0b4fc7e..f91e89d3c4c09f9d3cc87a0092b96d309d482785 100644 --- a/doc/ci/examples/test_phoenix_app_with_gitlab_ci_cd/index.md +++ b/doc/ci/examples/test_phoenix_app_with_gitlab_ci_cd/index.md @@ -10,7 +10,7 @@ last_updated: 2019-03-06 # Testing a Phoenix application with GitLab CI/CD -[Phoenix][phoenix-site] is a web development framework written in [Elixir][elixir-site], which is a +[Phoenix](https://phoenixframework.org) is a web development framework written in [Elixir](https://elixir-lang.org), which is a functional language designed for productivity and maintainability that runs on the [Erlang VM](https://www.erlang.org). Erlang VM is really really fast and can handle very large numbers of simultaneous users. @@ -27,8 +27,8 @@ and the GitLab UI. ### What is Phoenix? -[Phoenix][phoenix-site] is a web development framework written in [Elixir][elixir-site] very useful - to build fast, reliable, and high-performance applications, as it uses [Erlang VM](https://www.erlang.org). +[Phoenix](https://phoenixframework.org) is a web development framework written in [Elixir](https://elixir-lang.org) it's very useful + for building fast, reliable, and high-performance applications, as it uses [Erlang VM](https://www.erlang.org). Many components and concepts are similar to Ruby on Rails or Python's Django. High developer productivity and high application performance are only a few advantages on learning how to use it. @@ -45,27 +45,27 @@ Phoenix can run in any OS where Erlang is supported: - Fedora - Raspbian -Check the [Phoenix learning guide][phoenix-learning-guide] for more information. +Check the [Phoenix learning guide](https://hexdocs.pm/phoenix/learning.html) for more information. ### What is Elixir? -[Elixir][elixir-site] is a dynamic, functional language created to use all the maturity of Erlang +[Elixir](https://elixir-lang.org) is a dynamic, functional language created to use all the maturity of Erlang (30 years old!) in these days, in an easy way. It has similarities with Ruby, specially on syntax, so Ruby developers are quite excited with the rapid growing of Elixir. A full-stack Ruby developer can learn how to use Elixir and Phoenix in just a few weeks! In Elixir we have a command called `mix`, which is a helper to create projects, testing, run -migrations and [much more][elixir-mix]. We'll use it later on in this tutorial. +migrations and [much more](https://elixir-lang.org/getting-started/mix-otp/introduction-to-mix). We'll use it later on in this tutorial. -Check the [Elixir documentation][elixir-docs] for more information. +Check the [Elixir documentation](https://elixir-lang.org/getting-started/introduction) for more information. ## Requirements To follow this tutorial, you'll need to have installed: -- Elixir [installation instructions][elixir-install] -- Phoenix Framework [installation instructions][phoenix-install] -- PostgreSQL (if you need to use MySQL server, check [Phoenix instructions][phoenix-mysql]) +- Elixir [installation instructions](https://elixir-lang.org/install) +- Phoenix Framework [installation instructions](https://hexdocs.pm/phoenix/installation.html) +- PostgreSQL (if you need to use MySQL server, check [Phoenix instructions](https://hexdocs.pm/phoenix/ecto.html#using-mysql)) ### Create a new Phoenix project @@ -100,7 +100,7 @@ this case, we'll only create an empty database. First, we need to navigate to our recently created project's directory, and then execute again `mix`. This time, `mix` will receive the parameter `ecto.create`, which is the task to create our -new database. [Ecto][ecto] is the database wrapper for Elixir. +new database. [Ecto](https://hexdocs.pm/ecto/Ecto.html) is the database wrapper for Elixir. When we do run `mix` the first time after creating our project, it will compile our files to bytecode, which will be interpreted by Erlang VM. In the next times, it will only compile our @@ -123,7 +123,7 @@ The database for HelloGitlabCi.Repo has been created > **Note:** Phoenix assumes that our PostgreSQL database will have a `postgres` user account with the correct permissions and a password of `postgres`. If it's not your case, check -[Ecto's instructions][ecto-repo]. +[Ecto's instructions](https://hexdocs.pm/ecto/Ecto.html#module-repositories). ### Start Phoenix server @@ -151,7 +151,7 @@ point `localhost` to `127.0.0.1`. Great, now we have a local Phoenix Server running our app. -Locally, our application is running in an `iex` session. [iex][iex] stands for Interactive Elixir. +Locally, our application is running in an `iex` session. [iex](https://elixir-lang.org/getting-started/introduction.html#interactive-mode) stands for Interactive Elixir. In this interactive mode, we can type any Elixir expression and get its result. To exit `iex`, we need to press `Ctrl+C` twice. So, when we need to stop the Phoenix server, we have to hit `Ctrl+C` twice. @@ -245,17 +245,16 @@ Our test was successful. It's time to push our files to GitLab. The first step is to create a new file called `.gitlab-ci.yml` in `hello_gitlab_ci` directory of our project. -- The fastest and easiest way to do this, is to click on **Set up CI** on project's main page: +- The easiest way to do this is to click on **Set up CI/CD** on project's main page: -  +  -- On next screen, we can select a template ready to go. Click on **Apply a GitLab CI/CD Yaml - template** and select **Elixir**: +- On the next screen, we can use a template with Elixir tests already included. Click on **Apply a template** and select **Elixir**: -  +  This template file tells GitLab CI/CD about what we wish to do every time a new commit is made. - However, we have to adapt it to run a Phoenix app. + However, we have to adapt it slightly to run a Phoenix app. - The first line tells GitLab what Docker image will be used. @@ -263,21 +262,21 @@ project. our application? This virtual machine must have all dependencies to run our application. This is where a Docker image is needed. The correct image will provide the entire system for us. - As a suggestion, you can use [trenpixster's elixir image][docker-image], which already has all - dependencies for Phoenix installed, such as Elixir, Erlang, NodeJS and PostgreSQL: + As we are focusing on testing (not deploying), you can use the [elixir:latest](https://hub.docker.com/_/elixir) docker image, which already has the + dependencies for running Phoenix tests installed, such as Elixir and Erlang: ```yml - image: trenpixster/elixir:latest + image: elixir:latest ``` -- At `services` session, we'll only use `postgres`, so we'll delete `mysql` and `redis` lines: +- We'll only use `postgres`, so we can delete the `mysql` and `redis` lines from the `services` section: ```yml services: - postgres:latest ``` -- Now, we'll create a new entry called `variables`, before `before_script` session: +- Now, we'll create a new section called `variables`, before the `before_script` section: ```yml variables: @@ -288,54 +287,56 @@ project. MIX_ENV: "test" ``` - Here, we are setting up the values for GitLab CI/CD authenticate into PostgreSQL, as we did on - `config/test.exs` earlier. + Above, we set up the values for GitLab CI/CD to authenticate into PostgreSQL, like we did in + `config/test.exs` earlier. The `POSTGRES_USER` and `POSTGRES_PASSWORD` values + are used by the `postgres` service to create a user with those credentials. -- In `before_script` session, we'll add some commands to prepare everything to the test: +- In the `before_script` section, we'll add some commands to prepare everything for the test: ```yml before_script: - - apt-get update && apt-get -y install postgresql-client + - mix local.rebar --force - mix local.hex --force - mix deps.get --only test - mix ecto.create - mix ecto.migrate ``` - It's important to install `postgresql-client` to let GitLab CI/CD access PostgreSQL and create our - database with the login information provided earlier. More important is to respect the indentation, - to avoid syntax errors when running the build. + This ensures that [rebar3](https://www.rebar3.org) and [hex](https://hex.pm) are both installed + before attempting to fetch the dependencies that are required to run the tests. Next, the `postgres` db + is created and migrated with `ecto`, to ensure it's up-to-date. -- And finally, we'll let `mix` session intact. +- Finally, we'll leave the `mix` section unchanged. -Let's take a look at the completed file after the editions: +Let's take a look at the updated file after the changes: ```yml -image: trenpixster/elixir:latest +image: elixir:latest services: - postgres:latest variables: - POSTGRES_DB: test_test + POSTGRES_DB: hello_gitlab_ci_test POSTGRES_HOST: postgres POSTGRES_USER: postgres POSTGRES_PASSWORD: "postgres" MIX_ENV: "test" before_script: - - apt-get update && apt-get -y install postgresql-client - - mix deps.get + - mix local.rebar --force + - mix local.hex --force + - mix deps.get --only test - mix ecto.create - mix ecto.migrate mix: script: - - mix test + - mix test ``` For safety, we can check if we get any syntax errors before submitting this file to GitLab. Copy the -contents of `.gitlab-ci.yml` and paste it on [GitLab CI/CD Lint tool][ci-lint]. Please note that +contents of `.gitlab-ci.yml` and paste it on [GitLab CI/CD Lint tool](https://gitlab.com/ci/lint). Please note that this link will only work for logged in users. ## Watching the build @@ -374,7 +375,7 @@ see if our latest code is running without errors. When we finish this edition, GitLab will start another build and show a **build running** badge. It is expected, after all we just configured GitLab CI/CD to do this for every push! But you may think "Why run build and tests for simple things like editing README.md?" and it is a good question. -For changes that don't affect your application, you can add the keyword [`[ci skip]`][skipping-jobs] +For changes that don't affect your application, you can add the keyword [`[ci skip]`](../../yaml/README.md#skipping-jobs) to commit message and the build related to that commit will be skipped. In the end, we finally got our pretty green build succeeded badge! By outputting the result on the @@ -389,34 +390,12 @@ code permanently working. GitLab CI/CD is a time saving powerful tool to help us organized and working. As we could see in this post, GitLab CI/CD is really really easy to configure and use. We have [many -other reasons][ci-reasons] to keep using GitLab CI/CD. The benefits to our teams will be huge! +other reasons](https://about.gitlab.com/blog/2015/02/03/7-reasons-why-you-should-be-using-ci/) to keep +using GitLab CI/CD. The benefits to our teams will be huge! ## References -- [GitLab CI/CD introductory guide][ci-guide] -- [GitLab CI/CD full Documentation][ci-docs] -- [GitLab Runners documentation][gitlab-runners] -- [Using Docker images documentation][using-docker] -- [Example project: Hello GitLab CI/CD on GitLab][hello-gitlab] - -[phoenix-site]: https://phoenixframework.org/ "Phoenix Framework" -[phoenix-learning-guide]: https://hexdocs.pm/phoenix/learning.html "Phoenix Learning Guide" -[phoenix-install]: https://hexdocs.pm/phoenix/installation.html "Phoenix Installation" -[phoenix-mysql]: https://hexdocs.pm/phoenix/ecto.html#using-mysql "Phoenix with MySQL" -[elixir-site]: https://elixir-lang.org/ "Elixir" -[elixir-mix]: https://elixir-lang.org/getting-started/mix-otp/introduction-to-mix.html "Introduction to mix" -[elixir-docs]: https://elixir-lang.org/getting-started/introduction.html "Elixir Documentation" -[elixir-install]: https://elixir-lang.org/install.html "Elixir Installation" -[ecto]: https://hexdocs.pm/ecto/Ecto.html "Ecto" -[ecto-repo]: https://hexdocs.pm/ecto/Ecto.html#module-repositories "Ecto Repositories" -[mix-ecto]: https://hexdocs.pm/ecto/Mix.Tasks.Ecto.Create.html "mix and Ecto" -[iex]: https://elixir-lang.org/getting-started/introduction.html#interactive-mode "Interactive Mode" -[ci-lint]: https://gitlab.com/ci/lint "CI Lint Tool" -[ci-reasons]: https://about.gitlab.com/blog/2015/02/03/7-reasons-why-you-should-be-using-ci/ "7 Reasons Why You Should Be Using CI" -[ci-guide]: https://about.gitlab.com/blog/2015/12/14/getting-started-with-gitlab-and-gitlab-ci/ "Getting Started With GitLab And GitLab CI/CD" -[ci-docs]: ../../README.md "GitLab CI/CD Documentation" -[skipping-jobs]: ../../yaml/README.md#skipping-jobs "Skipping Jobs" -[gitlab-runners]: ../../runners/README.md "GitLab Runners Documentation" -[docker-image]: https://hub.docker.com/r/trenpixster/elixir/ "Elixir Docker Image" -[using-docker]: ../../docker/using_docker_images.md "Using Docker Images" -[hello-gitlab]: https://gitlab.com/Hostert/hello_gitlab_ci "Hello GitLab CI/CD" +- [GitLab CI/CD introductory guide](https://about.gitlab.com/blog/2015/12/14/getting-started-with-gitlab-and-gitlab-ci) +- [GitLab CI/CD full Documentation](../../README.md) +- [GitLab Runners documentation](../../runners/README.md) +- [Using Docker images documentation](../../docker/using_docker_images.md) diff --git a/doc/ci/img/parent_pipeline_graph_expanded_v12_6.png b/doc/ci/img/parent_pipeline_graph_expanded_v12_6.png new file mode 100644 index 0000000000000000000000000000000000000000..5c493109a543bd77a6be5a6fa768bab920e54c4a Binary files /dev/null and b/doc/ci/img/parent_pipeline_graph_expanded_v12_6.png differ diff --git a/doc/ci/img/pipeline-delete.png b/doc/ci/img/pipeline-delete.png new file mode 100644 index 0000000000000000000000000000000000000000..d9dba1f455d8eb720e4c9f3f0ed534e821a3e0a3 Binary files /dev/null and b/doc/ci/img/pipeline-delete.png differ diff --git a/doc/ci/jenkins/index.md b/doc/ci/jenkins/index.md index 6e9e723feb558e86779788657dda30aee136157d..92fc4de986c22fbcd9d6138bc1a426e6a444b076 100644 --- a/doc/ci/jenkins/index.md +++ b/doc/ci/jenkins/index.md @@ -26,7 +26,7 @@ There are some high level differences between the products worth mentioning: feature. - The `.gitlab-ci.yml` file is checked in to the root of your repository, much like a Jenkinsfile, but is in the YAML format (see [complete reference](../yaml/README.md)) instead of a Groovy DSL. It's most - analagous to the declarative Jenkinsfile format. + analogous to the declarative Jenkinsfile format. - GitLab comes with a [container registry](../../user/packages/container_registry/index.md), and we recommend using container images to set up your build environment. @@ -207,7 +207,7 @@ Because GitLab is integrated tightly with Git, SCM polling options for triggers #### `tools` -GitLab does not support a separate `tools` directive. Our best-practice reccomendation is to use pre-built +GitLab does not support a separate `tools` directive. Our best-practice recommendation is to use pre-built container images, which can be cached, and can be built to already contain the tools you need for your pipelines. Pipelines can be set up to automatically build these images as needed and deploy them to the [container registry](../../user/packages/container_registry/index.md). diff --git a/doc/ci/junit_test_reports.md b/doc/ci/junit_test_reports.md index f0c3da4358ac6160ac9c9e4f5747f59ade57d61a..8773f712110aa9aae85f8d2ff0353116575c72aa 100644 --- a/doc/ci/junit_test_reports.md +++ b/doc/ci/junit_test_reports.md @@ -25,7 +25,7 @@ Consider the following workflow: 1. Your `master` branch is rock solid, your project is using GitLab CI/CD and your pipelines indicate that there isn't anything broken. -1. Someone from you team submits a merge request, a test fails and the pipeline +1. Someone from your team submits a merge request, a test fails and the pipeline gets the known red icon. To investigate more, you have to go through the job logs to figure out the cause of the failed test, which usually contain thousands of lines. diff --git a/doc/ci/merge_request_pipelines/index.md b/doc/ci/merge_request_pipelines/index.md index 9ac41f88ff60f9c87a35e2e195d76b4f99115fab..8c3c17d2ce1c5e39a9d50d69b2470bebfe4e0190 100644 --- a/doc/ci/merge_request_pipelines/index.md +++ b/doc/ci/merge_request_pipelines/index.md @@ -136,7 +136,7 @@ Review App set up, helping to save resources. ## Excluding certain branches -Pipelines for merge requests require special treatement when +Pipelines for merge requests require special treatment when using [`only`/`except`](../yaml/README.md#onlyexcept-basic). Unlike ordinary branch refs (for example `refs/heads/my-feature-branch`), merge request refs use a special Git reference that looks like `refs/merge-requests/:iid/head`. Because @@ -161,7 +161,7 @@ test: only: [merge_requests] except: variables: - $CI_COMMIT_REF_NAME =~ /^docs-/ + - $CI_COMMIT_REF_NAME =~ /^docs-/ ``` ## Important notes about merge requests from forked projects diff --git a/doc/ci/merge_request_pipelines/pipelines_for_merged_results/img/merged_result_pipeline_v12_3.png b/doc/ci/merge_request_pipelines/pipelines_for_merged_results/img/merged_result_pipeline_v12_3.png index 6f0752bb940fe114900088fcdc123a07cbcc0482..8da2970ab5a6a0ecafcac7de9956817f00b9b38d 100644 Binary files a/doc/ci/merge_request_pipelines/pipelines_for_merged_results/img/merged_result_pipeline_v12_3.png and b/doc/ci/merge_request_pipelines/pipelines_for_merged_results/img/merged_result_pipeline_v12_3.png differ diff --git a/doc/ci/multi_project_pipelines.md b/doc/ci/multi_project_pipelines.md index f13d05716f1a712f8b77ec9cb3be3e4bc69f69be..6b84c3d6d123ad9257f0c3336da87e553c830c4e 100644 --- a/doc/ci/multi_project_pipelines.md +++ b/doc/ci/multi_project_pipelines.md @@ -93,8 +93,8 @@ gets created. If you want to display the downstream pipeline's status instead, s NOTE: **Note:** Bridge jobs do not support every configuration entry that a user can use -in the case of regular jobs. Bridge jobs will not to be picked by a Runner, -thus there is no point in adding support for `script`, for example. If a user +in the case of regular jobs. Bridge jobs will not be picked by a Runner, +so there is no point in adding support for `script`, for example. If a user tries to use unsupported configuration syntax, YAML validation will fail upon pipeline creation. @@ -221,6 +221,7 @@ Some features are not implemented yet. For example, support for environments. - `trigger` (to define a downstream pipeline trigger) - `stage` - `allow_failure` +- [`rules`](yaml/README.md#rules) - `only` and `except` - `when` (only with `on_success`, `on_failure`, and `always` values) - `extends` diff --git a/doc/ci/parent_child_pipelines.md b/doc/ci/parent_child_pipelines.md new file mode 100644 index 0000000000000000000000000000000000000000..269cbd75a9adf2e1f8512f63eceb6056affd68cc --- /dev/null +++ b/doc/ci/parent_child_pipelines.md @@ -0,0 +1,86 @@ +--- +type: reference +--- + +# Parent-child pipelines + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/16094) in GitLab Starter 12.7. + +As pipelines grow more complex, a few related problems start to emerge: + +- The staged structure, where all steps in a stage must be completed before the first + job in next stage begins, causes arbitrary waits, slowing things down. +- Configuration for the single global pipeline becomes very long and complicated, + making it hard to manage. +- Imports with [`include`](yaml/README.md#include) increase the complexity of the configuration, and create the potential + for namespace collisions where jobs are unintentionally duplicated. +- Pipeline UX can become unwieldy with so many jobs and stages to work with. + +Additionally, sometimes the behavior of a pipeline needs to be more dynamic. The ability +to choose to start sub-pipelines (or not) is a powerful ability, especially if the +YAML is dynamically generated. + + + +Similarly to [multi-project pipelines](multi_project_pipelines.md), a pipeline can trigger a +set of concurrently running child pipelines, but within the same project: + +- Child pipelines still execute each of their jobs according to a stage sequence, but + would be free to continue forward through their stages without waiting for unrelated + jobs in the parent pipeline to finish. +- The configuration is split up into smaller child pipeline configurations, which are + easier to understand. This reduces the cognitive load to understand the overall configuration. +- Imports are done at the child pipeline level, reducing the likelihood of collisions. +- Each pipeline has only the steps relevant steps, making it easier to understand what's going on. + +Child pipelines work well with other GitLab CI features: + +- Use [`only: changes`](yaml/README.md#onlychangesexceptchanges) to trigger pipelines only when + certain files change. This is useful for monorepos, for example. +- Since the parent pipeline in `.gitlab-ci.yml` and the child pipeline run as normal + pipelines, they can have their own behaviors and sequencing in relation to triggers. + +All of this will work with [`include:`](yaml/README.md#include) feature so you can compose +the child pipeline configuration. + +## Examples + +The simplest case is [triggering a child pipeline](yaml/README.md#trigger-premium) using a +local YAML file to define the pipeline configuration. In this case, the parent pipeline will +trigger the child pipeline, and continue without waiting: + +```yaml +microservice_a: + trigger: + include: path/to/microservice_a.yml +``` + +You can include multiple files when composing a child pipeline: + +```yaml +microservice_a: + trigger: + include: + - local: path/to/microservice_a.yml + - template: SAST.gitlab-ci.yml +``` + +NOTE: **Note:** +The max number of entries that are accepted for `trigger:include:` is three. + +Similar to [multi-project pipelines](multi_project_pipelines.md#mirroring-status-from-triggered-pipeline), +we can set the parent pipeline to depend on the status of the child pipeline upon completion: + +```yaml +microservice_a: + trigger: + include: + - local: path/to/microservice_a.yml + - template: SAST.gitlab-ci.yml + strategy: depend +``` + +## Limitations + +A parent pipeline can trigger many child pipelines, but a child pipeline cannot trigger +further child pipelines. See the [related issue](https://gitlab.com/gitlab-org/gitlab/issues/29651) for discussion on possible future improvements. diff --git a/doc/ci/pipelines.md b/doc/ci/pipelines.md index d1e50039417a7f7c4016fea0eacfa8b37ec71eb1..71c4c9ca0ecac0b668bf5bedb7aa3b655721c3c3 100644 --- a/doc/ci/pipelines.md +++ b/doc/ci/pipelines.md @@ -246,6 +246,13 @@ Pipelines for different projects can be combined and visualized together. For more information, see [Multi-project pipelines](multi_project_pipelines.md). +## Parent-child pipelines + +Complex pipelines can be broken down into one parent pipeline that can trigger +multiple child sub-pipelines, which all run in the same project and with the same SHA. + +For more information, see [Parent-Child pipelines](parent_child_pipelines.md). + ## Working with pipelines In general, pipelines are executed automatically and require no intervention once created. @@ -305,12 +312,14 @@ For example, the query string ### Accessing pipelines You can find the current and historical pipeline runs under your project's -**CI/CD > Pipelines** page. Clicking on a pipeline will show the jobs that were run for -that pipeline. +**CI/CD > Pipelines** page. You can also access pipelines for a merge request by navigating +to its **Pipelines** tab.  -You can also access pipelines for a merge request by navigating to its **Pipelines** tab. +Clicking on a pipeline will bring you to the **Pipeline Details** page and show +the jobs that were run for that pipeline. From here you can cancel a running pipeline, +retry jobs on a failed pipeline, or [delete a pipeline](#deleting-a-single-pipeline). ### Accessing individual jobs @@ -410,6 +419,20 @@ This functionality is only available: - For users with at least Developer access. - If the the stage contains [manual actions](#manual-actions-from-pipeline-graphs). +### Deleting a single pipeline + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/24851) in GitLab 12.7. + +Users with [owner permissions](../user/permissions.md) in a project can delete a pipeline +by clicking on the pipeline in the **CI/CD > Pipelines** to get to the **Pipeline Details** +page, then using the **Delete** button. + + + +CAUTION: **Warning:** +Deleting a pipeline will expire all pipeline caches, and delete all related objects, +such as builds, logs, artifacts, and triggers. **This action cannot be undone.** + ## Most Recent Pipeline > [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/issues/50499) in GitLab 12.3. diff --git a/doc/ci/quick_start/README.md b/doc/ci/quick_start/README.md index 68e977c1c98b786b81e20485d17226175d0cd843..55710145a95cf3f1a2229797e2d43b723d0b0fc3 100644 --- a/doc/ci/quick_start/README.md +++ b/doc/ci/quick_start/README.md @@ -17,34 +17,29 @@ NOTE: **Note:** Coming over to GitLab from Jenkins? Check out our [reference](../jenkins/index.md) for converting your pre-existing pipelines over to our format. -GitLab offers a [continuous integration][ci] service. If you -[add a `.gitlab-ci.yml` file][yaml] to the root directory of your repository, -and configure your GitLab project to use a [Runner], then each commit or -push triggers your CI [pipeline]. +GitLab offers a [continuous integration](https://about.gitlab.com/product/continuous-integration/) service. For each commit or push to trigger your CI +[pipeline](../pipelines.md), you must: -The `.gitlab-ci.yml` file tells the GitLab Runner what to do. By default it runs -a pipeline with three [stages]: `build`, `test`, and `deploy`. You don't need to -use all three stages; stages with no jobs are simply ignored. +- Add a [`.gitlab-ci.yml` file](#creating-a-gitlab-ciyml-file) to your repository's root directory. +- Ensure your project is configured to use a [Runner](#configuring-a-runner). -If everything runs OK (no non-zero return values), you'll get a nice green -checkmark associated with the commit. This makes it -easy to see whether a commit caused any of the tests to fail before -you even look at the code. +The `.gitlab-ci.yml` file tells the GitLab Runner what to do. A simple pipeline commonly has +three [stages](../yaml/README.md#stages): -Most projects use GitLab's CI service to run the test suite so that -developers get immediate feedback if they broke something. +- `build` +- `test` +- `deploy` -There's a growing trend to use continuous delivery and continuous deployment to -automatically deploy tested code to staging and production environments. +You do not need to use all three stages; stages with no jobs are ignored. -So in brief, the steps needed to have a working CI can be summed up to: +The pipeline appears under the project's **CI/CD > Pipelines** page. If everything runs OK (no non-zero +return values), you get a green check mark associated with the commit. This makes it easy to see +whether a commit caused any of the tests to fail before you even look at the job (test) log. Many projects use +GitLab's CI service to run the test suite, so developers get immediate feedback if they broke +something. -1. Add `.gitlab-ci.yml` to the root directory of your repository -1. Configure a Runner - -From there on, on every push to your Git repository, the Runner will -automatically start the pipeline and the pipeline will appear under the -project's **Pipelines** page. +It's also common to use pipelines to automatically deploy +tested code to staging and production environments. --- @@ -237,9 +232,4 @@ CI with various languages. [runner-install]: https://docs.gitlab.com/runner/install/ [blog-ci]: https://about.gitlab.com/blog/2015/05/06/why-were-replacing-gitlab-ci-jobs-with-gitlab-ci-dot-yml/ [examples]: ../examples/README.md -[ci]: https://about.gitlab.com/product/continuous-integration/ -[yaml]: ../yaml/README.md -[runner]: ../runners/README.md [enabled]: ../enable_or_disable_ci.md -[stages]: ../yaml/README.md#stages -[pipeline]: ../pipelines.md diff --git a/doc/ci/review_apps/index.md b/doc/ci/review_apps/index.md index 0da1228aa537b6014ab2814d14032515a022a88d..23cb0362867487a25d123e068ca859ef8da1e0e4 100644 --- a/doc/ci/review_apps/index.md +++ b/doc/ci/review_apps/index.md @@ -167,7 +167,7 @@ Ensure that the `anonymous_visual_review_feedback` feature flag is enabled. Administrators can enable with a Rails console as follows: ```ruby -Feature.enabled(:anonymous_visual_review_feedback) +Feature.enable(:anonymous_visual_review_feedback) ``` The feedback form is served through a script you add to pages in your Review App. diff --git a/doc/ci/runners/README.md b/doc/ci/runners/README.md index 4011ae4df7030f3269cf1755c8497d3db0c4cbb8..466c6f96d81c784c3da915e92f932da03a0895b7 100644 --- a/doc/ci/runners/README.md +++ b/doc/ci/runners/README.md @@ -62,13 +62,13 @@ You can only register a shared Runner if you are an admin of the GitLab instance 1. Grab the shared-Runner token on the `admin/runners` page -  +  1. [Register the Runner][register] Shared Runners are enabled by default as of GitLab 8.2, but can be disabled with the **Disable shared Runners** button which is present under each project's -**Settings ➔ CI/CD** page. Previous versions of GitLab defaulted shared +**Settings > CI/CD** page. Previous versions of GitLab defaulted shared Runners to disabled. ## Registering a specific Runner @@ -100,7 +100,7 @@ If you are an admin on your GitLab instance, you can turn any shared Runner into a specific one, but not the other way around. Keep in mind that this is a one way transition. -1. Go to the Runners in the admin area **Overview > Runners** (`/admin/runners`) +1. Go to the Runners in the **Admin Area > Overview > Runners** (`/admin/runners`) and find your Runner 1. Enable any projects under **Restrict projects for this Runner** to be used with the Runner @@ -402,7 +402,7 @@ different places. To view the IP address of a shared Runner you must have admin access to the GitLab instance. To determine this: -1. Visit **Admin area ➔ Overview ➔ Runners** +1. Visit **Admin Area > Overview > Runners** 1. Look for the Runner in the table and you should see a column for "IP Address"  @@ -411,7 +411,7 @@ the GitLab instance. To determine this: You can find the IP address of a Runner for a specific project by: -1. Visit your project's **Settings ➔ CI/CD** +1. Visit your project's **Settings > CI/CD** 1. Find the Runner and click on it's ID which links you to the details page 1. On the details page you should see a row for "IP Address" diff --git a/doc/ci/variables/README.md b/doc/ci/variables/README.md index 384216fed2cd4085273e38b0c75e1e7b0fe9b3f6..6dc093b6d0fac22d4202d109570db7765bd17eaa 100644 --- a/doc/ci/variables/README.md +++ b/doc/ci/variables/README.md @@ -290,6 +290,7 @@ export CI_RUNNER_ID="10" export CI_RUNNER_DESCRIPTION="my runner" export CI_RUNNER_TAGS="docker, linux" export CI_SERVER="yes" +export CI_SERVER_URL="https://example.com" export CI_SERVER_HOST="example.com" export CI_SERVER_NAME="GitLab" export CI_SERVER_REVISION="70606bf" @@ -673,6 +674,8 @@ Running on runner-8a2f473d-project-1796893-concurrent-0 via runner-8a2f473d-mach ++ CI_PROJECT_DIR=/builds/gitlab-examples/ci-debug-trace ++ export CI_SERVER=yes ++ CI_SERVER=yes +++ export CI_SERVER_URL=https://example.com:3000 +++ CI_SERVER_URL=https://example.com:3000 ++ export 'CI_SERVER_HOST=example.com' ++ CI_SERVER_HOST='example.com' ++ export 'CI_SERVER_NAME=GitLab CI' @@ -709,6 +712,8 @@ Running on runner-8a2f473d-project-1796893-concurrent-0 via runner-8a2f473d-mach ++ CI_JOB_NAME=debug_trace ++ export CI_JOB_STAGE=test ++ CI_JOB_STAGE=test +++ export CI_SERVER_URL=https://example.com:3000 +++ CI_SERVER_URL=https://example.com:3000 ++ export CI_SERVER_HOST=example.com ++ CI_SERVER_HOST=example.com ++ export CI_SERVER_NAME=GitLab diff --git a/doc/ci/variables/img/inherited_group_variables_v12_5.png b/doc/ci/variables/img/inherited_group_variables_v12_5.png index f9043df051c72c62c39d9058b352d566f16e6faf..fd41859605f2b2f4487e4a39f226986fc2ca5a68 100644 Binary files a/doc/ci/variables/img/inherited_group_variables_v12_5.png and b/doc/ci/variables/img/inherited_group_variables_v12_5.png differ diff --git a/doc/ci/variables/predefined_variables.md b/doc/ci/variables/predefined_variables.md index e25425b01ae506ad2e6e5d548d7a412df645300c..24a16fe6a704ce0667a08baa2d896fc05683e28a 100644 --- a/doc/ci/variables/predefined_variables.md +++ b/doc/ci/variables/predefined_variables.md @@ -79,13 +79,14 @@ future GitLab releases.** | `CI_MERGE_REQUEST_TARGET_BRANCH_NAME` | 11.6 | all | The target branch name of the merge request if [the pipelines are for merge requests](../merge_request_pipelines/index.md). Available only if `only: [merge_requests]` is used and the merge request is created. | | `CI_MERGE_REQUEST_TARGET_BRANCH_SHA` | 11.9 | all | The HEAD SHA of the target branch of the merge request if [the pipelines are for merge requests](../merge_request_pipelines/index.md). Available only if `only: [merge_requests]` is used, the merge request is created, and the pipeline is a [merged result pipeline](../merge_request_pipelines/pipelines_for_merged_results/index.md). **(PREMIUM)** | | `CI_MERGE_REQUEST_TITLE` | 11.9 | all | The title of the merge request if [the pipelines are for merge requests](../merge_request_pipelines/index.md). Available only if `only: [merge_requests]` is used and the merge request is created. | +| `CI_MERGE_REQUEST_EVENT_TYPE` | 12.3 | all | The event type of the merge request, if [the pipelines are for merge requests](../merge_request_pipelines/index.md). Can be `detached`, `merged_result` or `merge_train`. | | `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_PAGES_DOMAIN` | 11.8 | all | The configured domain that hosts GitLab Pages. | | `CI_PAGES_URL` | 11.8 | all | URL to GitLab Pages-built pages. Always belongs to a subdomain of `CI_PAGES_DOMAIN`. | | `CI_PIPELINE_ID` | 8.10 | all | 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_SOURCE` | 10.0 | all | Indicates how the pipeline was triggered. Possible options are: `push`, `web`, `trigger`, `schedule`, `api`, `pipeline` and `merge_request_event`. For pipelines created before GitLab 9.5, this will show as `unknown` | +| `CI_PIPELINE_SOURCE` | 10.0 | all | Indicates how the pipeline was triggered. Possible options are: `push`, `web`, `trigger`, `schedule`, `api`, `pipeline`, `external`, `chat`, `merge_request_event`, and `external_pull_request_event`. For pipelines created before GitLab 9.5, this will show as `unknown` | | `CI_PIPELINE_TRIGGERED` | all | all | The flag to indicate that job was [triggered](../triggers/README.md) | | `CI_PIPELINE_URL` | 11.1 | 0.5 | Pipeline details URL | | `CI_PROJECT_DIR` | all | all | The full path where the repository is cloned and where the job is run. If the GitLab Runner `builds_dir` parameter is set, this variable is set relative to the value of `builds_dir`. For more information, see [Advanced configuration](https://docs.gitlab.com/runner/configuration/advanced-configuration.html#the-runners-section) for GitLab Runner. | @@ -111,6 +112,7 @@ future GitLab releases.** | `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_SERVER` | all | all | Mark that job is executed in CI environment | +| `CI_SERVER_URL` | 12.7 | all | The base URL of the GitLab instance, including protocol and port (like `https://gitlab.example.com:8080`) | | `CI_SERVER_HOST` | 12.1 | all | Host component of the GitLab instance URL, without protocol and port (like `gitlab.example.com`) | | `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 | diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md index 84e0bb873a752b0ed3108de6384e8ef20f9f1c54..aafbe4c9189866dbb2350246c075278feee60bb4 100644 --- a/doc/ci/yaml/README.md +++ b/doc/ci/yaml/README.md @@ -119,6 +119,7 @@ The following table lists available parameters for jobs: | [`pages`](#pages) | Upload the result of a job to use with GitLab Pages. | | [`variables`](#variables) | Define job variables on a job level. | | [`interruptible`](#interruptible) | Defines if a job can be canceled when made redundant by a newer run. | +| [`resource_group`](#resource_group) | Limit job concurrency. | NOTE: **Note:** Parameters `types` and `type` are [deprecated](#deprecated-parameters). @@ -728,7 +729,18 @@ Learn more about [variables expressions](../variables/README.md#environment-vari Using the `changes` keyword with `only` or `except` makes it possible to define if a job should be created based on files modified by a Git push event. -For example: +This means the `only:changes` policy is useful for pipelines where: + +- `$CI_PIPELINE_SOURCE == 'push'` +- `$CI_PIPELINE_SOURCE == 'merge_request_event'` +- `$CI_PIPELINE_SOURCE == 'external_pull_request_event'` + +If there is no Git push event, such as for pipelines with +[sources other than the three above](../variables/predefined_variables.html#variables-reference), +`changes` cannot determine if a given file is new or old, and will always +return true. + +A basic example of using `only: changes`: ```yaml docker build: @@ -829,7 +841,7 @@ In the example above, a pipeline could fail due to changes to a file in `service A later commit could then be pushed that does not include any changes to this file, but includes changes to the `Dockerfile`, and this pipeline could pass because it is only testing the changes to the `Dockerfile`. GitLab checks the **most recent pipeline**, -that **passed**, and will show the merge request as mergable, despite the earlier +that **passed**, and will show the merge request as mergeable, despite the earlier failed pipeline caused by a change that was not yet corrected. With this configuration, care must be taken to check that the most recent pipeline @@ -909,8 +921,9 @@ at all, the behavior defaults to `job:when`, which continues to default to #### `rules:changes` -`changes` works exactly the same way as [`only`/`except`](#onlychangesexceptchanges), -accepting an array of paths. +`rules: changes` works exactly the same way as `only: changes` and `except: changes`, +accepting an array of paths. Similarly, it will always return true if there is no +Git push event. See [`only/except: changes`](#onlychangesexceptchanges) for more information. For example: @@ -2312,6 +2325,23 @@ This example creates three paths of execution: - Related to the above, stages must be explicitly defined for all jobs that have the keyword `needs:` or are referred to by one. +##### Changing the `needs:` job limit + +The maximum number of jobs that can be defined within `needs:` defaults to 10, but +can be changed to 50 via a feature flag. To change the limit to 50, +[start a Rails console session](https://docs.gitlab.com/omnibus/maintenance/#starting-a-rails-console-session) +and run: + +```ruby +Feature::disable(:ci_dag_limit_needs) +``` + +To set it back to 10, run the opposite command: + +```ruby +Feature::enable(:ci_dag_limit_needs) +``` + #### Artifact downloads with `needs` > [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/14311) in GitLab v12.6. @@ -2355,6 +2385,51 @@ rspec: - build_job_3 ``` +#### Cross project artifact downloads with `needs` **(PREMIUM)** + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/14311) in GitLab v12.7. + +`needs` can be used to download artifacts from up to five jobs in pipelines on +[other refs in the same project](#artifact-downloads-between-pipelines-in-the-same-project), +or pipelines in different projects: + +```yaml +build_job: + stage: build + script: + - ls -lhR + needs: + - project: group/project-name + job: build-1 + ref: master + artifacts: true +``` + +`build_job` will download the artifacts from the latest successful `build-1` job +on the `master` branch in the `group/project-name` project. + +##### Artifact downloads between pipelines in the same project + +`needs` can be used to download artifacts from different pipelines in the current project +by setting the `project` keyword as the current project's name, and specifying a ref. +In the example below, `build_job` will download the artifacts for the latest successful +`build-1` job with the `other-ref` ref: + +```yaml +build_job: + stage: build + script: + - ls -lhR + needs: + - project: group/same-project-name + job: build-1 + ref: other-ref + artifacts: true +``` + +NOTE: **Note:** +Downloading artifacts from jobs that are run in [`parallel:`](#parallel) is not supported. + ### `coverage` > [Introduced][ce-7447] in GitLab 8.17. @@ -2525,14 +2600,17 @@ job split into three separate jobs. from `trigger` definition is started by GitLab, a downstream pipeline gets created. -Learn more about [multi-project pipelines](../multi_project_pipelines.md#creating-multi-project-pipelines-from-gitlab-ciyml). +This keyword allows the creation of two different types of downstream pipelines: + +- [Multi-project pipelines](../multi_project_pipelines.md#creating-multi-project-pipelines-from-gitlab-ciyml) +- [Child pipelines](../parent_child_pipelines.md) NOTE: **Note:** Using a `trigger` with `when:manual` together results in the error `jobs:#{job-name} when should be on_success, on_failure or always`, because `when:manual` prevents triggers being used. -#### Simple `trigger` syntax +#### Simple `trigger` syntax for multi-project pipelines The simplest way to configure a downstream trigger is to use `trigger` keyword with a full path to a downstream project: @@ -2547,7 +2625,7 @@ staging: trigger: my/deployment ``` -#### Complex `trigger` syntax +#### Complex `trigger` syntax for multi-project pipelines It is possible to configure a branch name that GitLab will use to create a downstream pipeline with: @@ -2582,6 +2660,28 @@ upstream_bridge: pipeline: other/project ``` +#### `trigger` syntax for child pipeline + +To create a [child pipeline](../parent_child_pipelines.md), specify the path to the +YAML file containing the CI config of the child pipeline: + +```yaml +trigger_job: + trigger: + include: path/to/child-pipeline.yml +``` + +Similar to [multi-project pipelines](../multi_project_pipelines.md#mirroring-status-from-triggered-pipeline), +it is possible to mirror the status from a triggered pipeline: + +```yaml +trigger_job: + trigger: + include: + - local: path/to/child-pipeline.yml + strategy: depend +``` + ### `interruptible` > [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/23464) in GitLab 12.3. @@ -2634,6 +2734,39 @@ In the example above, a new pipeline run will cause an existing running pipeline NOTE: **Note:** Once an uninterruptible job is running, the pipeline will never be canceled, regardless of the final job's state. +### `resource_group` + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/15536) in GitLab 12.7. + +Sometimes running multiples jobs or pipelines at the same time in an environment +can lead to errors during the deployment. + +To avoid these errors, the `resource_group` attribute can be used to ensure that +the Runner will not run certain jobs simultaneously. + +When the `resource_group` key is defined for a job in `.gitlab-ci.yml`, +job executions are mutually exclusive across different pipelines for the same project. +If multiple jobs belonging to the same resource group are enqueued simultaneously, +only one of the jobs will be picked by the Runner, and the other jobs will wait until the +`resource_group` is free. + +Here is a simple example: + +```yaml +deploy-to-production: + script: deploy + resource_group: production +``` + +In this case, if a `deploy-to-production` job is running in a pipeline, and a new +`deploy-to-production` job is created in a different pipeline, it will not run until +the currently running/pending `deploy-to-production` job is finished. As a result, +you can ensure that concurrent deployments will never happen to the production environment. + +There can be multiple `resource_group`s defined per environment. A good use case for this +is when deploying to physical devices. You may have more than one physical device, and each +one can be deployed to, but there can be only one deployment per device at any given time. + ### `include` > - Introduced in [GitLab Premium](https://about.gitlab.com/pricing/) 10.5. @@ -3594,7 +3727,7 @@ having their own custom `script` defined: ```yaml .job_template: &job_definition # Hidden key that defines an anchor named 'job_definition' - image: ruby:2.1 + image: ruby:2.6 services: - postgres - redis @@ -3616,13 +3749,13 @@ given hash into the current one", and `*` includes the named anchor ```yaml .job_template: - image: ruby:2.1 + image: ruby:2.6 services: - postgres - redis test1: - image: ruby:2.1 + image: ruby:2.6 services: - postgres - redis @@ -3630,7 +3763,7 @@ test1: - test1 project test2: - image: ruby:2.1 + image: ruby:2.6 services: - postgres - redis diff --git a/doc/development/README.md b/doc/development/README.md index 3a972c4c5885db1b9aae294aad41538d1bf4ff59..d551e6f471ed6edc934aa223e0fba713258aeb74 100644 --- a/doc/development/README.md +++ b/doc/development/README.md @@ -17,7 +17,7 @@ description: 'Learn how to contribute to GitLab.' - [GitLab core team & GitLab Inc. contribution process](https://gitlab.com/gitlab-org/gitlab/blob/master/PROCESS.md) - [Generate a changelog entry with `bin/changelog`](changelog.md) - [Code review guidelines](code_review.md) for reviewing code and having code reviewed -- [Database review guidelines](database_review.md) for reviewing database-related changes and complex SQL queries +- [Database review guidelines](database_review.md) for reviewing database-related changes and complex SQL queries, and having them reviewed - [Pipelines for the GitLab project](pipelines.md) - [Guidelines for implementing Enterprise Edition features](ee_features.md) - [Security process for developers](https://gitlab.com/gitlab-org/release/docs/blob/master/general/security/developer.md#security-releases-critical-non-critical-as-a-developer) @@ -71,6 +71,8 @@ description: 'Learn how to contribute to GitLab.' - [Auto DevOps development guide](auto_devops.md) - [Mass Inserting Models](mass_insert.md) - [Cycle Analytics development guide](cycle_analytics.md) +- [Issue types vs first-class types](issue_types.md) +- [Application limits](application_limits.md) ## Performance guides @@ -106,10 +108,10 @@ description: 'Learn how to contribute to GitLab.' ### Debugging - Tracing the source of an SQL query using query comments with [Marginalia](database_query_comments.md) +- Tracing the source of an SQL query in Rails console using [Verbose Query Logs](https://guides.rubyonrails.org/debugging_rails_applications.html#verbose-query-logs) ### Best practices -- [Merge Request checklist](database_merge_request_checklist.md) - [Adding database indexes](adding_database_indexes.md) - [Foreign keys & associations](foreign_keys.md) - [Single table inheritance](single_table_inheritance.md) diff --git a/doc/development/api_graphql_styleguide.md b/doc/development/api_graphql_styleguide.md index 1ef0b928820f16eec3287f4cedad277820e1f807..053eb55420ee11a3f0507f008f05c230673e2173 100644 --- a/doc/development/api_graphql_styleguide.md +++ b/doc/development/api_graphql_styleguide.md @@ -210,7 +210,7 @@ class MergeRequestPermissionsType < BasePermissionType abilities :admin_merge_request, :update_merge_request, :create_note ability_field :resolve_note, - description: 'Whether or not the user can resolve disussions on the merge request' + description: 'Indicates the user can resolve discussions on the merge request' permission_field :push_to_source_branch, method: :can_push_to_source_branch? end ``` diff --git a/doc/development/api_styleguide.md b/doc/development/api_styleguide.md index 71963ee0c0a45ec17cd640d24096cbc12a1dfaba..d5fc24c1ddbc3bc2b0c67e22d77051f2b8d3e9b1 100644 --- a/doc/development/api_styleguide.md +++ b/doc/development/api_styleguide.md @@ -92,6 +92,12 @@ For instance: Model.create(foo: params[:foo]) ``` +## Using HTTP status helpers + +For non-200 HTTP responses, use the provided helpers in `lib/api/helpers.rb` to ensure correct behaviour (`not_found!`, `no_content!` etc.). These will `throw` inside Grape and abort the execution of your endpoint. + +For `DELETE` requests, you should also generally use the `destroy_conditionally!` helper which by default returns a `204 No Content` response on success, or a `412 Precondition Failed` response if the given `If-Unmodified-Since` header is out of range. This helper calls `#destroy` on the passed resource, but you can also implement a custom deletion method by passing a block. + ## Using API path helpers in GitLab Rails codebase Because we support [installing GitLab under a relative URL], one must take this diff --git a/doc/development/application_limits.md b/doc/development/application_limits.md new file mode 100644 index 0000000000000000000000000000000000000000..28d1f14b1b3bc7190d434e1e20637c956396f881 --- /dev/null +++ b/doc/development/application_limits.md @@ -0,0 +1,89 @@ +# Application limits development + +This document provides a development guide for contributors to add application +limits to GitLab. + +## Documentation + +First of all, you have to gather information and decide which are the different +limits that will be set for the different GitLab tiers. You also need to +coordinate with others to [document](../administration/instance_limits.md) +and communicate those limits. + +There is a guide about [introducing application +limits](https://about.gitlab.com/handbook/product/#introducing-application-limits). + +## Development + +The merge request to [configure maximum number of webhooks per +project](https://gitlab.com/gitlab-org/gitlab/merge_requests/20730/diffs) is a +good example about configuring application limits. + +### Insert database plan limits + +In the `plan_limits` table, you have to create a new column and insert the +limit values. It's recommended to create separate migration script files. + +1. Add new column to the `plan_limits` table with non-null default value 0, eg: + + ```ruby + add_column(:plan_limits, :project_hooks, :integer, default: 0, null: false) + ``` + + NOTE: **Note:** Plan limits entries set to `0` mean that limits are not + enabled. + +1. Insert plan limits values into the database using + `create_or_update_plan_limit` migration helper, eg: + + ```ruby + create_or_update_plan_limit('project_hooks', 'free', 10) + create_or_update_plan_limit('project_hooks', 'bronze', 20) + create_or_update_plan_limit('project_hooks', 'silver', 30) + create_or_update_plan_limit('project_hooks', 'gold', 100) + ``` + +### Plan limits validation + +#### Get current limit + +Access to the current limit can be done through the project or the namespace, +eg: + +```ruby +project.actual_limits.project_hooks +``` + +#### Check current limit + +There is one method `PlanLimits#exceeded?` to check if the current limit is +being exceeded. You can use either an `ActiveRecord` object or an `Integer`. + +Ensures that the count of the records does not exceed the defined limit, eg: + +```ruby +project.actual_limits.exceeded?(:project_hooks, ProjectHook.where(project: project)) +``` + +Ensures that the number does not exceed the defined limit, eg: + +```ruby +project.actual_limits.exceeded?(:project_hooks, 10) +``` + +#### `Limitable` concern + +The [`Limitable` concern](https://gitlab.com/gitlab-org/gitlab/blob/master/ee/app/models/concerns/ee/limitable.rb) +can be used to validate that a model does not exceed the limits. It ensures +that the count of the records for the current model does not exceed the defined +limit. + +NOTE: **Note:** The name (pluralized) of the plan limit introduced in the +database (`project_hooks`) must correspond to the name of the model we are +validating (`ProjectHook`). + +```ruby +class ProjectHook + include Limitable +end +``` diff --git a/doc/development/architecture.md b/doc/development/architecture.md index eff83da523be012b84ef0d3e11b56b6d26618644..778cc1aa1d7313e29d846dfdfdba4087575c0aea 100644 --- a/doc/development/architecture.md +++ b/doc/development/architecture.md @@ -625,7 +625,7 @@ Note: It is recommended to log into the `git` user using `sudo -i -u git` or `su ## GitLab.com -We've also detailed [our architecture of GitLab.com](https://about.gitlab.com/handbook/engineering/infrastructure/production-architecture/) but this is probably over the top unless you have millions of users. +We've also detailed [our architecture of GitLab.com](https://about.gitlab.com/handbook/engineering/infrastructure/production/architecture/) but this is probably over the top unless you have millions of users. [alertmanager-omnibus]: https://gitlab.com/gitlab-org/omnibus-gitlab/blob/master/files/gitlab-config-template/gitlab.rb.template [alertmanager-charts]: https://github.com/helm/charts/tree/master/stable/prometheus diff --git a/doc/development/code_review.md b/doc/development/code_review.md index 326ac7b3a3702e9cc83572d599303c929abd11d6..4bc963222f2cb6c8c354c7723d6704792d5cfeeb 100644 --- a/doc/development/code_review.md +++ b/doc/development/code_review.md @@ -259,24 +259,34 @@ Developers who have capacity can regularly check the list of [merge requests to Since [unblocking others is always a top priority](https://about.gitlab.com/handbook/values/#global-optimization), reviewers are expected to review assigned merge requests in a timely manner, even when this may negatively impact their other tasks and priorities. + Doing so allows everyone involved in the merge request to iterate faster as the -context is fresh in memory, improves contributors' experiences significantly. +context is fresh in memory, and improves contributors' experience significantly. + +#### Review-response SLO + +To ensure swift feedback to ready-to-review code, we maintain a `Review-response` Service-level Objective (SLO). The SLO is defined as: -A turnaround time of two working days is usually acceptable, since engineers -will typically have other things to work on while they're waiting for review, -but don't hesitate to ask the author if it's unclear what time frame would be -acceptable, how urgent the review is, or how significant the blockage. +> - review-response SLO = (time when first review response is provided) - (time MR is assigned to reviewer) < 2 business days -If you don't think you'll be able to review a merge request within a reasonable +If you don't think you'll be able to review a merge request within the `Review-response` SLO time frame, let the author know as soon as possible and try to help them find another reviewer or maintainer who will be able to, so that they can be unblocked -and get on with their work quickly. Of course, if you are out of office and have +and get on with their work quickly. + +If you think you are at capacity and are unable to accept any more reviews until +some have been completed, communicate this through your GitLab status by setting +the `:red_circle:` emoji and mentioning that you are at capacity in the status +text. This will guide contributors to pick a different reviewer, helping us to +meet the SLO. + +Of course, if you are out of office and have [communicated](https://about.gitlab.com/handbook/paid-time-off/#communicating-your-time-off) this through your GitLab.com Status, authors are expected to realize this and find a different reviewer themselves. -When a merge request author feels like they have been blocked for longer than -is reasonable, they are free to remind the reviewer through Slack or assign +When a merge request author has been blocked for longer than +the `Review-response` SLO, they are free to remind the reviewer through Slack or assign another reviewer. ### Reviewing code @@ -304,7 +314,7 @@ experience, refactors the existing code). Then: - Ensure the target branch is not too far behind master. If [master is red](https://about.gitlab.com/handbook/engineering/workflow/#broken-master), it should be no more than 100 commits behind. -- Consider warnings and errors from danger bot, codequality, and other reports. +- Consider warnings and errors from danger bot, code quality, and other reports. Unless a strong case can be made for the violation, these should be resolved before merge. - Ensure a passing CI pipeline or if [master is broken](https://about.gitlab.com/handbook/engineering/workflow/#broken-master), post a comment mentioning the failure happens in master with a diff --git a/doc/development/contributing/issue_workflow.md b/doc/development/contributing/issue_workflow.md index a385a7dc83a35edb38a908db2d6b470318d1f5b3..eba650ea22f85ccd65a40f89d7ff267d373d72c6 100644 --- a/doc/development/contributing/issue_workflow.md +++ b/doc/development/contributing/issue_workflow.md @@ -29,7 +29,7 @@ the affected files to find someone. We also use [GitLab Triage](https://gitlab.com/gitlab-org/gitlab-triage) to automate some triaging policies. This is currently set up as a scheduled pipeline (`https://gitlab.com/gitlab-org/quality/triage-ops/pipeline_schedules/10512/editpipeline_schedules/10512/edit`, -must have at least developer access to the project) running on [quality/triage-ops](https://gitlab.com/gitlab-org/quality/triage-ops) +must have at least Developer access to the project) running on [quality/triage-ops](https://gitlab.com/gitlab-org/quality/triage-ops) project. ## Labels @@ -185,9 +185,9 @@ their color is `#428BCA`. `<Category Name>` is the category name as it is in the single source of truth for categories at <https://gitlab.com/gitlab-com/www-gitlab-com/blob/master/data/categories.yml>. -For instance, the "Code Analytics" category is represented by the -~"Category:Code Analytics" label in the `gitlab-org` group since its -`code_analytics.name` value is "Code Analytics". +For instance, the "DevOps Score" category is represented by the +~"Category:DevOps Score" label in the `gitlab-org` group since its +`devops_score.name` value is "DevOps Score". If a category's label doesn't respect this naming convention, it should be specified with [the `label` attribute](https://about.gitlab.com/handbook/marketing/website/#category-attributes) diff --git a/doc/development/cycle_analytics.md b/doc/development/cycle_analytics.md index 284645cdae7c39fad34ec3031df35e3b811f426a..90abafb3e03b40c6755ffe235bb2249a4ac859a0 100644 --- a/doc/development/cycle_analytics.md +++ b/doc/development/cycle_analytics.md @@ -77,8 +77,8 @@ end Some start/end event pairs are not "compatible" with each other. For example: -- "Issue created" to "Merge Request created": The event classes are defined on different domain models, the `object_type` method is different. -- "Issue closed" to "Issue created": Issue must be created first before it can be closed. +- "Issue created" to "Merge Request created": The event classes are defined on different domain models, the `object_type` method is different. +- "Issue closed" to "Issue created": Issue must be created first before it can be closed. - "Issue closed" to "Issue closed": Duration is always 0. The `StageEvents` module describes the allowed `start_event` and `end_event` pairings (`PAIRING_RULES` constant). If a new event is added, it needs to be registered in this module. diff --git a/doc/development/dangerbot.md b/doc/development/dangerbot.md index 40eb4294617306509a042c03d39f00d87f27abb0..51f7a18dd0871059a2c8f7e1d64029fef6655e82 100644 --- a/doc/development/dangerbot.md +++ b/doc/development/dangerbot.md @@ -119,4 +119,16 @@ at GitLab so far: ## Limitations - [`danger local` does not work on GitLab](https://github.com/danger/danger/issues/458) -- Danger output is not added to a merge request comment if working on a fork. +- Danger output is not added to a merge request comment if working on + a fork. This happens because the secret variable from the canonical + project is not shared to forks. + To work around this, you can add an [environment + variable](../ci/variables/README.md) called + `DANGER_GITLAB_API_TOKEN` with a personal API token to your + fork. That way the danger comments will be made from CI using that + API token instead. + Making the variable + [masked](../ci/variables/README.md#masked-variables) will make sure + it doesn't show up in the job logs. The variable cannot be + [protected](../ci/variables/README.md#protected-environment-variables), + as it needs to be present for all feature branches. diff --git a/doc/development/database_merge_request_checklist.md b/doc/development/database_merge_request_checklist.md deleted file mode 100644 index 09dece27e8d2e4c8b9f65eaba077018a46d24028..0000000000000000000000000000000000000000 --- a/doc/development/database_merge_request_checklist.md +++ /dev/null @@ -1,15 +0,0 @@ -# Merge Request Checklist - -When creating a merge request that performs database related changes (schema -changes, adjusting queries to optimize performance, etc) you should use the -merge request template called "Database changes". This template contains a -checklist of steps to follow to make sure the changes are up to snuff. - -To use the checklist, create a new merge request and click on the "Choose a -template" dropdown, then click "Database changes". - -An example of this checklist can be found at -<https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/12463>. - -The source code of the checklist can be found in at -<https://gitlab.com/gitlab-org/gitlab/blob/master/.gitlab/merge_request_templates/Database%20changes.md> diff --git a/doc/development/database_review.md b/doc/development/database_review.md index b1c3ed4797686d6117d204ec31b9cb65c8ee5a7a..5ca77579eec0ef85e0002a8b091b2ec091f4a647 100644 --- a/doc/development/database_review.md +++ b/doc/development/database_review.md @@ -32,12 +32,10 @@ for review. ### Roles and process -A Merge Request author's role is to: +A Merge Request **author**'s role is to: - Decide whether a database review is needed. - If database review is needed, add the ~database label. -- Use the [database changes](https://gitlab.com/gitlab-org/gitlab/blob/master/.gitlab/merge_request_templates/Database%20changes.md) - merge request template, or include the appropriate items in the MR description. - [Prepare the merge request for a database review](#how-to-prepare-the-merge-request-for-a-database-review). A database **reviewer**'s role is to: @@ -78,15 +76,54 @@ make sure you have applied the ~database label and rerun the ### How to prepare the merge request for a database review -In order to make reviewing easier and therefore faster, please consider preparing a comment -and details for a database reviewer: +In order to make reviewing easier and therefore faster, please take +the following preparations into account. -- Provide queries in SQL form rather than ActiveRecord. -- Format any queries with a SQL query formatter, for example with [sqlformat.darold.net](http://sqlformat.darold.net). -- Consider providing query plans via a link to [explain.depesz.com](https://explain.depesz.com) or another tool instead of textual form. -- For query changes, it is best to provide the SQL query along with a plan *before* and *after* the change. This helps to spot differences quickly. -- When providing query plans, make sure to use good parameter values, so that the query executed is a good example and also hits enough data. - - Usually, the `gitlab-org` namespace (`namespace_id = 9970`) and the `gitlab-org/gitlab-foss` (`project_id = 13083`) or the `gitlab-org/gitlab` (`project_id = 278964`) projects provide enough data to serve as a good example. +#### Preparation when adding migrations + +- Ensure `db/schema.rb` is updated. +- Make migrations reversible by using the `change` method or include a `down` method when using `up`. + - Include either a rollback procedure or describe how to rollback changes. +- Add the output of the migration(s) to the MR description. +- Add tests for the migration in `spec/migrations` if necessary. See [Testing Rails migrations at GitLab](testing_guide/testing_migrations_guide.html) for more details. + +#### Preparation when adding or modifying queries + +- Write the raw SQL in the MR description. Preferably formatted + nicely with [sqlformat.darold.net](http://sqlformat.darold.net) or + [paste.depesz.com](https://paste.depesz.com). +- Include the output of `EXPLAIN (ANALYZE, BUFFERS)` of the relevant + queries in the description. If the output is too long, wrap it in + `<details>` blocks, paste it in a GitLab Snippet, or provide the + link to the plan at: [explain.depesz.com](https://explain.depesz.com). +- When providing query plans, make sure it hits enough data: + - You can use a GitLab production replica to test your queries on a large scale, + through the `#database-lab` Slack channel or through [chatops](understanding_explain_plans.md#chatops). + - Usually, the `gitlab-org` namespace (`namespace_id = 9970`) and the + `gitlab-org/gitlab-foss` (`project_id = 13083`) or the `gitlab-org/gitlab` (`project_id = 278964`) + projects provide enough data to serve as a good example. +- For query changes, it is best to provide the SQL query along with a + plan _before_ and _after_ the change. This helps to spot differences + quickly. +- Include data that shows the performance improvement, preferably in + the form of a benchmark. + +#### Preparation when adding foreign keys to existing tables + +- Include a migration to remove orphaned rows in the source table **before** adding the foreign key. +- Remove any instances of `dependent: ...` that may no longer be necessary. + +#### Preparation when adding tables + +- Order columns based on the [Ordering Table Columns](ordering_table_columns.md) guidelines. +- Add foreign keys to any columns pointing to data in other tables, including [an index](migration_style_guide.md#adding-foreign-key-constraints). +- Add indexes for fields that are used in statements such as `WHERE`, `ORDER BY`, `GROUP BY`, and `JOIN`s. + +#### Preparation when removing columns, tables, indexes or other structures + +- Follow the [guidelines on dropping columns](what_requires_downtime.md#dropping-columns). +- Generally it's best practice, but not a hard rule, to remove indexes and foreign keys in a post-deployment migration. + - Exceptions include removing indexes and foreign keys for small tables. ### How to review for database @@ -136,6 +173,7 @@ and details for a database reviewer: (eg indexes, columns), you can use a [one-off instance from the restore pipeline](https://ops.gitlab.net/gitlab-com/gl-infra/gitlab-restore/postgres-gprd) in order to establish a proper testing environment. + - Avoid N+1 problems and minimalize the [query count](merge_request_performance_guidelines.md#query-counts). ### Timing guidelines for migrations diff --git a/doc/development/documentation/index.md b/doc/development/documentation/index.md index 61e42ecfe832310da7d38923f2658f261558325a..a9d8941488f37142511bb96d572e22f72b996696 100644 --- a/doc/development/documentation/index.md +++ b/doc/development/documentation/index.md @@ -208,7 +208,7 @@ available online on 2018-09-15, but, as the feature freeze date has passed, if the MR does not have a "pick into 11.3" label, the milestone has to be changed to 11.4 and it will be shipped with all GitLab packages only on 2018-10-22, with GitLab 11.4. Meaning, it will only be available under `/help` from GitLab -11.4 onwards, but available on <https://docs.gitlab.com/> on the same day it was merged. +11.4 onward, but available on <https://docs.gitlab.com/> on the same day it was merged. ### Linking to `/help` diff --git a/doc/development/documentation/site_architecture/global_nav.md b/doc/development/documentation/site_architecture/global_nav.md index be4d5b5353f4b285b6b04be9cbfce05309eff83f..518850358ffd6fac89ff384aef5d51ad49d8471d 100644 --- a/doc/development/documentation/site_architecture/global_nav.md +++ b/doc/development/documentation/site_architecture/global_nav.md @@ -86,7 +86,7 @@ The available sections are described on the table below: | Section | Description | | ------------- | ------------------------------------------ | | User | Documentation for the GitLab's user UI. | -| Administrator | Documentation for the GitLab's admin area. | +| Administrator | Documentation for the GitLab's Admin Area. | | Contributor | Documentation for developing GitLab. | The majority of the links available on the nav were added according to the UI. diff --git a/doc/development/documentation/styleguide.md b/doc/development/documentation/styleguide.md index 385569fc8faadc920f8d44a9ae51896dcb5453a4..fd591c71e85cc777875b0c51b6f52b8e6845d280 100644 --- a/doc/development/documentation/styleguide.md +++ b/doc/development/documentation/styleguide.md @@ -941,7 +941,7 @@ a helpful link back to how the feature was developed. Over time, version text will reference a progressively older version of GitLab. In cases where version text refers to versions of GitLab four or more major versions back, consider removing the text. -For example, if the current major version is 11.x, version text referencing versions of GitLab 7.x +For example, if the current major version is 12.x, version text referencing versions of GitLab 8.x and older are candidates for removal. NOTE: **Note:** diff --git a/doc/development/documentation/workflow.md b/doc/development/documentation/workflow.md index e48c940dc215d4839c54e7c0905696deb82f1864..0e5da29df94e1bbe219dbc35be3a5a4d9d60d48a 100644 --- a/doc/development/documentation/workflow.md +++ b/doc/development/documentation/workflow.md @@ -284,7 +284,7 @@ To update GitLab documentation: 1. Follow GitLab's [Merge Request Guidelines](../contributing/merge_request_workflow.md#merge-request-guidelines). TIP: **Tip:** -Work in a fork if you do not have developer access to the GitLab project. +Work in a fork if you do not have Developer access to the GitLab project. Request help from the Technical Writing team if you: @@ -297,8 +297,7 @@ To request help: 1. Locate the the Technical Writer for the relevant [DevOps stage group](https://about.gitlab.com/handbook/product/technical-writing/index.html#assignments). 1. Either: - - If urgent help is required, directly assign the Technical Writer in the issue or - [in the merge request](../../user/project/merge_requests/creating_merge_requests.md#multiple-assignees-starter). + - If urgent help is required, directly assign the Technical Writer in the issue or in the merge request. - If non-urgent help is required, ping the Technical Writer in the issue or merge request. If you are a member of GitLab's Slack workspace, you can request help in `#docs`. diff --git a/doc/development/elasticsearch.md b/doc/development/elasticsearch.md index 1375bd6d56dfe794c5a58aa81af52956f038477e..bd8a4e1c6d7c876d7a2bb07d9b2f0fc3bea0da73 100644 --- a/doc/development/elasticsearch.md +++ b/doc/development/elasticsearch.md @@ -37,20 +37,6 @@ brew install elasticsearch@5.6 There is no need to install any plugins -## New repo indexer (beta) - -If you're interested on working with the new beta repo indexer, all you need to do is: - -```sh -git clone git@gitlab.com:gitlab-org/gitlab-elasticsearch-indexer.git -make -make install -``` - -this adds `gitlab-elasticsearch-indexer` to `$GOPATH/bin`, please make sure that is in your `$PATH`. After that GitLab will find it and you'll be able to enable it in the admin settings area. - -**note:** `make` will not recompile the executable unless you do `make clean` beforehand - ## Helpful rake tasks - `gitlab:elastic:test:index_size`: Tells you how much space the current index is using, as well as how many documents are in the index. diff --git a/doc/development/event_tracking/index.md b/doc/development/event_tracking/index.md index ac19053320d6686fe060f641b530cb6783c087a4..13b08e537685191814176b4a7aa2cbb345ebf641 100644 --- a/doc/development/event_tracking/index.md +++ b/doc/development/event_tracking/index.md @@ -53,7 +53,7 @@ Tracking can be enabled at: We utilize Snowplow for the majority of our tracking strategy, and it can be enabled by navigating to: -- **Admin area > Settings > Integrations** in the UI. +- **Admin Area > Settings > Integrations** in the UI. - `admin/application_settings/integrations` in your browser. The following configuration is required: diff --git a/doc/development/fe_guide/design_patterns.md b/doc/development/fe_guide/design_patterns.md index a7a0f39e2f31644c1156277883d071bec54f58ce..72a7861ffcb90278960f1509b60e75df2683147f 100644 --- a/doc/development/fe_guide/design_patterns.md +++ b/doc/development/fe_guide/design_patterns.md @@ -31,11 +31,11 @@ export default new MyThing(); export default class MyThing { constructor() { - if (!this.prototype.singleton) { + if (!MyThing.prototype.singleton) { this.init(); - this.prototype.singleton = this; + MyThing.prototype.singleton = this; } - return this.prototype.singleton; + return MyThing.prototype.singleton; } init() { diff --git a/doc/development/fe_guide/frontend_faq.md b/doc/development/fe_guide/frontend_faq.md index cbe0a78370d053a7b7f33d5ad869b1977d67fc8a..01ed07f8736daf0436c1017cd490c1c47259dc5e 100644 --- a/doc/development/fe_guide/frontend_faq.md +++ b/doc/development/fe_guide/frontend_faq.md @@ -73,3 +73,76 @@ Ensure a [Product Designer](https://about.gitlab.com/company/team/?department=ux reviews the use of the non-conforming component as part of the MR review. Make a follow up issue and attach it to the component implementation epic found within the [Components of Pajamas Design System epic](https://gitlab.com/groups/gitlab-org/-/epics/973). + +### 4. My submit form button becomes disabled after submitting + +If you are using a submit button inside a form and you attach an `onSubmit` event listener on the form element, [this piece of code](https://gitlab.com/gitlab-org/gitlab/blob/794c247a910e2759ce9b401356432a38a4535d49/app/assets/javascripts/main.js#L225) will add a `disabled` class selector to the submit button when the form is submitted. +To avoid this behavior, add the class `js-no-auto-disable` to the button. + +### 5. Should I use a full URL (i.e. `gon.gitlab_url`) or a full path (i.e. `gon.relative_url_root`) when referencing backend endpoints? + +It's preferred to use a **full path** over a **full URL** because the URL will use the hostname configured with +GitLab which may not match the request. This will cause [CORS issues like this Web IDE one](https://gitlab.com/gitlab-org/gitlab/issues/36810). + +Example: + +```javascript +// bad :( +// If gitlab is configured with hostname `0.0.0.0` +// This will cause CORS issues if I request from `localhost` +axios.get(joinPaths(gon.gitlab_url, '-', 'foo')) + +// good :) +axios.get(joinPaths(gon.relative_url_root, '-', 'foo')) +``` + +Also, please try not to hardcode paths in the Frontend, but instead receive them from the Backend (see next section). +When referencing Backend rails paths, avoid using `*_url`, and use `*_path` instead. + +Example: + +```haml +-# Bad :( +#js-foo{ data: { foo_url: some_rails_foo_url } } + +-# Good :) +#js-foo{ data: { foo_path: some_rails_foo_path } } +``` + +### 6. How should the Frontend reference Backend paths? + +We prefer not to add extra coupling by hardcoding paths. If possible, +add these paths as data attributes to the DOM element being referenced in the JavaScript. + +Example: + +```javascript +// Bad :( +// Here's a Vuex action that hardcodes a path :( +export const fetchFoos = ({ state }) => { + return axios.get(joinPaths(gon.relative_url_root, '-', 'foo')); +}; + +// Good :) +function initFoo() { + const el = document.getElementById('js-foo'); + + // Path comes from our root element's data which is used to initialize the store :) + const store = createStore({ + fooPath: el.dataset.fooPath + }); + + Vue.extend({ + store, + el, + render(h) { + return h(Component); + }, + }); +} + +// Vuex action can now reference the path from it's state :) +export const fetchFoos = ({ state }) => { + return axios.get(state.settings.fooPath); +}; +``` diff --git a/doc/development/fe_guide/graphql.md b/doc/development/fe_guide/graphql.md index 40b9fdef76ea75a8691252053d1136a594a740a3..1639029d193126c07e9bca9c3ba9ef268966c494 100644 --- a/doc/development/fe_guide/graphql.md +++ b/doc/development/fe_guide/graphql.md @@ -109,17 +109,16 @@ import createDefaultClient from '~/lib/graphql'; Vue.use(VueApollo); const defaultClient = createDefaultClient({ - Query: { - ... - }, - Mutations: { - ... - }, + resolvers: {} }); defaultClient.cache.writeData({ data: { - isLoading: true, + user: { + name: 'John', + surname: 'Doe', + age: 30 + }, }, }); @@ -128,8 +127,122 @@ const apolloProvider = new VueApollo({ }); ``` +We can query local data with `@client` Apollo directive: + +```javascript +// user.query.graphql + +query User { + user @client { + name + surname + age + } +} +``` + +Along with creating local data, we can also extend existing GraphQL types with `@client` fields. This is extremely useful when we need to mock an API responses for fields not yet added to our GraphQL API. + +#### Mocking API response with local Apollo cache + +Using local Apollo Cache is handy when we have a need to mock some GraphQL API responses, queries or mutations locally (e.g. when they're still not added to our actual API). + +For example, we have a [fragment](#fragments) on `DesignVersion` used in our queries: + +``` +fragment VersionListItem on DesignVersion { + id + sha +} +``` + +We need to fetch also version author and the 'created at' property to display them in the versions dropdown but these changes are still not implemented in our API. We can change the existing fragment to get a mocked response for these new fields: + +``` +fragment VersionListItem on DesignVersion { + id + sha + author @client { + avatarUrl + name + } + createdAt @client +} +``` + +Now Apollo will try to find a _resolver_ for every field marked with `@client` directive. Let's create a resolver for `DesignVersion` type (why `DesignVersion`? because our fragment was created on this type). + +```javascript +// resolvers.js + +const resolvers = { + DesignVersion: { + author: () => ({ + avatarUrl: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + name: 'Administrator', + __typename: 'User', + }), + createdAt: () => '2019-11-13T16:08:11Z', + }, +}; + +export default resolvers; +``` + +We need to pass resolvers object to our existing Apollo Client: + +```javascript +// graphql.js + +import createDefaultClient from '~/lib/graphql'; +import resolvers from './graphql/resolvers'; + +const defaultClient = createDefaultClient( + {}, + resolvers, +); +``` + +Now every single time on attempt to fetch a version, our client will fetch `id` and `sha` from the remote API endpoint and will assign our hardcoded values to `author` and `createdAt` version properties. With this data, frontend developers are able to work on UI part without being blocked by backend. When actual response is added to the API, a custom local resolver can be removed fast and the only change to query/fragment is `@client` directive removal. + Read more about local state management with Apollo in the [Vue Apollo documentation](https://vue-apollo.netlify.com/guide/local-state.html#local-state). +### Feature flags in queries + +Sometimes it may be useful to have an entity in the GraphQL query behind a feature flag. +For example, when working on a feature where the backend has already been merged but the frontend +hasn't you might want to put the GraphQL entity behind a feature flag to allow for smaller +merge requests to be created and merged. + +To do this we can use the `@include` directive to exclude an entity if the `if` statement passes. + +```graphql +query getAuthorData($authorNameEnabled: Boolean = false) { + username + name @include(if: $authorNameEnabled) +} +``` + +Then in the Vue (or JavaScript) call to the query we can pass in our feature flag. This feature +flag will need to be already setup correctly. See the [feature flag documentation](../feature_flags/development.md) +for the correct way to do this. + +```javascript +export default { + apollo: { + user: { + query: QUERY_IMPORT, + variables() { + return { + authorNameEnabled: gon?.features?.authorNameEnabled, + }; + }, + } + }, +}; +``` + ### Testing #### Mocking response as component data diff --git a/doc/development/fe_guide/img/graphiql_explorer_v12_4.png b/doc/development/fe_guide/img/graphiql_explorer_v12_4.png index 8981b37ba237a193d619ed6e57477c7e60c8d7e4..b50424f7f8de5e3fdfd6a95a3cb555becc62f72f 100644 Binary files a/doc/development/fe_guide/img/graphiql_explorer_v12_4.png and b/doc/development/fe_guide/img/graphiql_explorer_v12_4.png differ diff --git a/doc/development/fe_guide/style/vue.md b/doc/development/fe_guide/style/vue.md index 2499623e66aa738c1cfc299cae7be3fe4af686d6..8f69792287babd5b7a75a3524dccb55572b7ea34 100644 --- a/doc/development/fe_guide/style/vue.md +++ b/doc/development/fe_guide/style/vue.md @@ -7,8 +7,8 @@ Please check this [rules](https://github.com/vuejs/eslint-plugin-vue#bulb-rules) ## Basic Rules -1. The service has it's own file -1. The store has it's own file +1. The service has its own file +1. The store has its own file 1. Use a function in the bundle file to instantiate the Vue component: ```javascript @@ -268,7 +268,7 @@ Please check this [rules](https://github.com/vuejs/eslint-plugin-vue#bulb-rules) ## Closing tags -1. Prefer self closing component tags +1. Prefer self-closing component tags ```javascript // bad @@ -411,8 +411,8 @@ The goal of this accord is to make sure we are all on the same page. 1. You may use a jQuery dependency in Vue.js following [this example from the docs](https://vuejs.org/v2/examples/select2.html). 1. If an outside jQuery Event needs to be listen to inside the Vue application, you may use jQuery event listeners. 1. We will avoid adding new jQuery events when they are not required. Instead of adding new jQuery events take a look at [different methods to do the same task](https://vuejs.org/v2/api/#vm-emit). -1. You may query the `window` object 1 time, while bootstrapping your application for application specific data (e.g. `scrollTo` is ok to access anytime). Do this access during the bootstrapping of your application. -1. You may have a temporary but immediate need to create technical debt by writing code that does not follow our standards, to be refactored later. Maintainers need to be ok with the tech debt in the first place. An issue should be created for that tech debt to evaluate it further and discuss. In the coming months you should fix that tech debt, with it's priority to be determined by maintainers. +1. You may query the `window` object one time, while bootstrapping your application for application specific data (e.g. `scrollTo` is ok to access anytime). Do this access during the bootstrapping of your application. +1. You may have a temporary but immediate need to create technical debt by writing code that does not follow our standards, to be refactored later. Maintainers need to be ok with the tech debt in the first place. An issue should be created for that tech debt to evaluate it further and discuss. In the coming months you should fix that tech debt, with its priority to be determined by maintainers. 1. When creating tech debt you must write the tests for that code before hand and those tests may not be rewritten. e.g. jQuery tests rewritten to Vue tests. 1. You may choose to use VueX as a centralized state management. If you choose not to use VueX, you must use the *store pattern* which can be found in the [Vue.js documentation](https://vuejs.org/v2/guide/state-management.html#Simple-State-Management-from-Scratch). -1. Once you have chosen a centralized state management solution you must use it for your entire application. i.e. Don't mix and match your state management solutions. +1. Once you have chosen a centralized state-management solution you must use it for your entire application. i.e. Don't mix and match your state-management solutions. diff --git a/doc/development/feature_flags/process.md b/doc/development/feature_flags/process.md index c64b14a05a432cf7de96ef0c22ad86f3512bd39a..4b44c8dadca68780e80a0b81f7b33b0cffeba25e 100644 --- a/doc/development/feature_flags/process.md +++ b/doc/development/feature_flags/process.md @@ -15,7 +15,9 @@ should be leveraged: - Feature flags should remain in the codebase for as short period as possible to reduce the need for feature flag accounting. - The person operating with feature flags is responsible for clearly communicating - the status of a feature behind the feature flag with responsible stakeholders. + the status of a feature behind the feature flag with responsible stakeholders. The + issue description should be updated with the feature flag name and whether it is + defaulted on or off as soon it is evident that a feature flag is needed. - Merge requests that make changes hidden behind a feature flag, or remove an existing feature flag because a feature is deemed stable must have the ~"feature flag" label assigned. diff --git a/doc/development/foreign_keys.md b/doc/development/foreign_keys.md index 0ab0deb156f63a2aa8792179b6035cee886931ec..38b60ce6f0bc722b492018361977392dd02270dc 100644 --- a/doc/development/foreign_keys.md +++ b/doc/development/foreign_keys.md @@ -61,3 +61,29 @@ introduces non database logic to a model, and means we can no longer rely on foreign keys to remove the data as this would result in the filesystem data being left behind. In such a case you should use a service class instead that takes care of removing non database data. + +## Alternative primary keys with has_one associations + +Sometimes a `has_one` association is used to create a one-to-one relationship: + +```ruby +class User < ActiveRecord::Base + has_one :user_config +end + +class UserConfig < ActiveRecord::Base + belongs_to :user +end +``` + +In these cases, there may be an opportunity to remove the unnecessary `id` +column on the associated table, `user_config.id` in this example. Instead, +the originating table ID can be used as the primary key for the associated +table: + +```ruby +create_table :user_configs, id: false do |t| + t.references :users, primary_key: true, default: nil, index: false, foreign_key: { on_delete: :cascade } + ... +end +``` diff --git a/doc/development/gitaly.md b/doc/development/gitaly.md index 1fa555de99488cf7e18b691e6205c8e2b8b1544d..20bba134ef174b18ec8589a1b3c1a9edfe8ca4c4 100644 --- a/doc/development/gitaly.md +++ b/doc/development/gitaly.md @@ -207,6 +207,21 @@ To use a custom Gitaly repository in CI, for instance if you want your GitLab fork to always use your own Gitaly fork, set `GITALY_REPO_URL` as a [CI environment variable](../ci/variables/README.md#gitlab-cicd-environment-variables). +### Use a locally modified version of Gitaly RPC client + +If you are making changes to the RPC client, such as adding a new endpoint or adding a new +parameter to an existing endpoint, follow the guide for +[Gitaly proto](https://gitlab.com/gitlab-org/gitaly/blob/master/proto/README.md). After pushing +the branch with the changes (`new-feature-branch`, for example): + +1. Change the `gitaly` line in the Rails' `Gemfile` to: + + ```ruby + gem 'gitaly', git: 'https://gitlab.com/gitlab-org/gitaly.git', branch: 'new-feature-branch' + ``` + +1. Run `bundle install` to use the modified RPC client. + --- [Return to Development documentation](README.md) diff --git a/doc/development/go_guide/index.md b/doc/development/go_guide/index.md index 724bc240bc2f6760326f6cf6c3d9b46c6cc81f4b..f6aae945f62f7dfc903cf3bf7615244eacd33820 100644 --- a/doc/development/go_guide/index.md +++ b/doc/development/go_guide/index.md @@ -78,13 +78,27 @@ projects: All Go projects should include these GitLab CI/CD jobs: ```yaml -go lint: - image: golang:1.11 +lint: + image: registry.gitlab.com/gitlab-org/gitlab-build-images:golangci-lint-alpine + stage: test script: - - go get -u golang.org/x/lint/golint - - golint -set_exit_status $(go list ./... | grep -v "vendor/") + # Use default .golangci.yml file from the image if one is not present in the project root. + - '[ -e .golangci.yml ] || cp /golangci/.golangci.yml .' + # Write the code coverage report to gl-code-quality-report.json + # and print linting issues to stdout in the format: path/to/file:line description + - golangci-lint run --out-format code-climate | tee gl-code-quality-report.json | jq -r '.[] | "\(.location.path):\(.location.lines.begin) \(.description)"' + artifacts: + reports: + codequality: gl-code-quality-report.json + paths: + - gl-code-quality-report.json + allow_failure: true ``` +Including a `.golangci.yml` in the root directory of the project allows for +configuration of `golangci-lint`. All options for `golangci-lint` are listed in +this [example](https://github.com/golangci/golangci-lint/blob/master/.golangci.example.yml). + Once [recursive includes](https://gitlab.com/gitlab-org/gitlab-foss/issues/56836) become available, you will be able to share job templates like this [analyzer](https://gitlab.com/gitlab-org/security-products/ci-templates/raw/master/includes-dev/analyzer.yml). diff --git a/doc/development/gotchas.md b/doc/development/gotchas.md index 7529278f90239ea4f354815dbe00d7e15b71ceb6..09d0d71b3d7f3ff46e92b4a973ee8ca5d47f7d1d 100644 --- a/doc/development/gotchas.md +++ b/doc/development/gotchas.md @@ -26,7 +26,7 @@ describe API::Labels do get api("/projects/#{project.id}/labels", user) - expect(response).to have_http_status(200) + expect(response).to have_gitlab_http_status(:ok) expect(json_response.first['name']).to eq('label1') end @@ -35,7 +35,7 @@ describe API::Labels do get api("/projects/#{project.id}/labels", user) - expect(response).to have_http_status(200) + expect(response).to have_gitlab_http_status(:ok) expect(json_response.first['name']).to eq('label1') end end @@ -77,7 +77,7 @@ describe API::Labels do get api("/projects/#{project.id}/labels", user) - expect(response).to have_http_status(200) + expect(response).to have_gitlab_http_status(:ok) expect(json_response.first['name']).to eq('foo') end @@ -86,7 +86,7 @@ describe API::Labels do get api("/projects/#{project.id}/labels", user) - expect(response).to have_http_status(200) + expect(response).to have_gitlab_http_status(:ok) expect(json_response.first['name']).to eq('bar') end end diff --git a/doc/development/i18n/proofreader.md b/doc/development/i18n/proofreader.md index 8b3a5d893fe6f1b36fa2188a7d60a7e639cab3b2..a475f854ab0fd63a8e92a389fdb7f53008f6deec 100644 --- a/doc/development/i18n/proofreader.md +++ b/doc/development/i18n/proofreader.md @@ -25,7 +25,7 @@ are very appreciative of the work done by translators and proofreaders! - Victor Wu - [GitLab](https://gitlab.com/victorwuky), [Crowdin](https://crowdin.com/profile/victorwu) - Ivan Ip - [GitLab](https://gitlab.com/lifehome), [Crowdin](https://crowdin.com/profile/lifehome) - Czech - - Proofreaders needed. + - Jan Urbanec - [GitLab](https://gitlab.com/TatranskyMedved), [Crowdin](https://crowdin.com/profile/Tatranskymedved) - Danish - Saederup92 - [GitLab](https://gitlab.com/Saederup92), [Crowdin](https://crowdin.com/profile/Saederup92) - Dutch @@ -58,6 +58,7 @@ are very appreciative of the work done by translators and proofreaders! - Japanese - Hiroyuki Sato - [GitLab](https://gitlab.com/hiroponz), [Crowdin](https://crowdin.com/profile/hiroponz) - Tomo Dote - [GitLab](https://gitlab.com/fu7mu4), [Crowdin](https://crowdin.com/profile/fu7mu4) + - Hiromi Nozawa - [GitLab](https://gitlab.com/hir0mi), [Crowdin](https://crowdin.com/profile/hir0mi) - Korean - Chang-Ho Cha - [GitLab](https://gitlab.com/changho-cha), [Crowdin](https://crowdin.com/profile/zzazang) - Ji Hun Oh - [GitLab](https://gitlab.com/Baw-Appie), [Crowdin](https://crowdin.com/profile/BawAppie) @@ -82,6 +83,7 @@ are very appreciative of the work done by translators and proofreaders! - Alexy Lustin - [GitLab](https://gitlab.com/allustin), [Crowdin](https://crowdin.com/profile/lustin) - Mark Minakou - [GitLab](https://gitlab.com/sandzhaj), [Crowdin](https://crowdin.com/profile/sandzhaj) - NickVolynkin - [Crowdin](https://crowdin.com/profile/NickVolynkin) + - Andrey Komarov - [GitLab](https://gitlab.com/elkamarado), [Crowdin](https://crowdin.com/profile/kamarado) - Serbian (Cyrillic) - Proofreaders needed. - Serbian (Latin) diff --git a/doc/development/img/build_package_v12_6.png b/doc/development/img/build_package_v12_6.png index c3d99e6c6ce12fd3430b2dd4837756b1bd81627b..32a3ebedba493acdb4457a986001549b156c49f4 100644 Binary files a/doc/development/img/build_package_v12_6.png and b/doc/development/img/build_package_v12_6.png differ diff --git a/doc/development/img/memory_ruby_heap_fragmentation.png b/doc/development/img/memory_ruby_heap_fragmentation.png index 6567abe58bbfd0600fc39db03baa8e968affc36a..4703da7491d9439a7af7eb26ea79ca17f95034a0 100644 Binary files a/doc/development/img/memory_ruby_heap_fragmentation.png and b/doc/development/img/memory_ruby_heap_fragmentation.png differ diff --git a/doc/development/img/trigger_build_package_v12_6.png b/doc/development/img/trigger_build_package_v12_6.png index 6f5879bd8c44e9932b1bac0615712598ac838936..ca6797ebf65377e3e72eacf60fe2065f6dfa76be 100644 Binary files a/doc/development/img/trigger_build_package_v12_6.png and b/doc/development/img/trigger_build_package_v12_6.png differ diff --git a/doc/development/issue_types.md b/doc/development/issue_types.md new file mode 100644 index 0000000000000000000000000000000000000000..bcd3980c2981bf091ac2b0abc16c4e95c7c27db5 --- /dev/null +++ b/doc/development/issue_types.md @@ -0,0 +1,45 @@ +# Issue Types + +Sometimes when a new resource type is added it's not clear if it should be only an +"extension" of Issue (Issue Type) or if it should be a new first-class resource type +(similar to Issue, Epic, Merge Request, Snippet). + +The idea of Issue Types was first proposed in [this +issue](https://gitlab.com/gitlab-org/gitlab/issues/8767) and its usage was +discussed few times since then, for example in [incident +management](https://gitlab.com/gitlab-org/gitlab-foss/issues/55532). + +## What is an Issue Type + +Issue Type is a resource type which extends the existing Issue type and can be +used anywhere where Issue is used - for example when listing or searching +issues or when linking objects of the type from Epics. It should use the same +`issues` table, additional fields can be stored in a separate table. + +## When an Issue Type should be used + +- When the new type only adds new fields to the basic Issue type without + removing existing fields (but it's OK if some fields from the basic Issue + type are hidden in user interface/API). +- When the new type can be used anywhere where the basic Issue type is used. + +## When a first-class resource type should be used + +- When a separate model and table is used for the new resource. +- When some fields of the basic Issue type need to be removed - hiding in the UI + is OK, but not complete removal. +- When the new resource cannot be used instead of the basic Issue type, + for example: + + - You can't add it to an epic. + - You can't close it from a commit or a merge request. + - You can't mark it as related to another issue. + +If an Issue type can not be used you can still define a first-class type and +then include concerns such as `Issuable` or `Noteable` to reuse functionality +which is common for all our issue-related resources. But you still need to +define the interface for working with the new resource and update some other +components to make them work with the new type. + +Usage of the Issue type limits what fields, functionality, or both is available +for the type. However, this functionality is provided by default. diff --git a/doc/development/logging.md b/doc/development/logging.md index 2eb140d3b7e2b15c605649546fef008fec41a5f7..202c7a5ce9f786790ed9eaea7fee47156157b3a7 100644 --- a/doc/development/logging.md +++ b/doc/development/logging.md @@ -127,6 +127,118 @@ importer progresses. Here's what to do: logger.info(message: "Import error", error_code: 1, error: "I/O failure") ``` +## Multi-destination Logging + +GitLab is transitioning from unstructured/plaintext logs to structured/JSON logs. During this transition period some logs will be recorded in multiple formats through multi-destination logging. + +### How to use multi-destination logging + +Create a new logger class, inheriting from `MultiDestinationLogger` and add an array of loggers to a `LOGGERS` constant. The loggers should be classes that descend from `Gitlab::Logger`. e.g. the user defined loggers in the following examples, could be inheriting from `Gitlab::Logger` and `Gitlab::JsonLogger`, respectively. + +You must specify one of the loggers as the `primary_logger`. The `primary_logger` will be used when information about this multi-destination logger is displayed in the app, e.g. using the `Gitlab::Logger.read_latest` method. + +The following example sets one of the defined `LOGGERS` as a `primary_logger`. + +```ruby +module Gitlab + class FancyMultiLogger < Gitlab::MultiDestinationLogger + LOGGERS = [UnstructuredLogger, StructuredLogger].freeze + + def self.loggers + LOGGERS + end + + def primary_logger + UnstructuredLogger + end + end +end +``` + +You can now call the usual logging methods on this multi-logger, e.g. + +```ruby +FancyMultiLogger.info(message: "Information") +``` + +This message will be logged by each logger registered in `FancyMultiLogger.loggers`. + +### Passing a string or hash for logging + +When passing a string or hash to a `MultiDestinationLogger`, the log lines could be formatted differently, depending on the kinds of `LOGGERS` set. + +e.g. let's partially define the loggers from the previous example: + +```ruby +module Gitlab + # Similar to AppTextLogger + class UnstructuredLogger < Gitlab::Logger + ... + end + + # Similar to AppJsonLogger + class StructuredLogger < Gitlab::JsonLogger + ... + end +end +``` + +Here are some examples of how messages would be handled by both the loggers. + +1. When passing a string + +```ruby +FancyMultiLogger.info("Information") + +# UnstructuredLogger +I, [2020-01-13T18:48:49.201Z #5647] INFO -- : Information + +# StructuredLogger +{:severity=>"INFO", :time=>"2020-01-13T11:02:41.559Z", :correlation_id=>"b1701f7ecc4be4bcd4c2d123b214e65a", :message=>"Information"} +``` + +1. When passing a hash + +```ruby +FancyMultiLogger.info({:message=>"This is my message", :project_id=>123}) + +# UnstructuredLogger +I, [2020-01-13T19:01:17.091Z #11056] INFO -- : {"message"=>"Message", "project_id"=>"123"} + +# StructuredLogger +{:severity=>"INFO", :time=>"2020-01-13T11:06:09.851Z", :correlation_id=>"d7e0886f096db9a8526a4f89da0e45f6", :message=>"This is my message", :project_id=>123} +``` + +### Logging context metadata (through Rails or Grape requests) + +`Gitlab::ApplicationContext` stores metadata in a request +lifecycle, which can then be added to the web request +or Sidekiq logs. + +Entry points can be seen at: + +- [`ApplicationController`](https://gitlab.com/gitlab-org/gitlab/blob/master/app/controllers/application_controller.rb) +- [External API](https://gitlab.com/gitlab-org/gitlab/blob/master/lib/api/api.rb) +- [Internal API](https://gitlab.com/gitlab-org/gitlab/blob/master/lib/api/internal/base.rb) + +#### Adding attributes + +When adding new attributes, make sure they're exposed within the context of the entry points above and: + +- Pass them within the hash to the `with_context` (or `push`) method (make sure to pass a Proc if the +method or variable shouldn't be evaluated right away) +- Change `Gitlab::ApplicationContext` to accept these new values +- Make sure the new attributes are accepted at [`Labkit::Context`](https://gitlab.com/gitlab-org/labkit-ruby/blob/master/lib/labkit/context.rb) + +See our [HOWTO: Use Sidekiq metadata logs](https://www.youtube.com/watch?v=_wDllvO_IY0) for further knowledge on +creating visualizations in Kibana. + +**Note:** +The fields of the context are currently only logged for Sidekiq jobs triggered +through web requests. See the +[follow-up work](https://gitlab.com/gitlab-com/gl-infra/scalability/issues/68) +for more information. + ## Exception Handling It often happens that you catch the exception and want to track it. diff --git a/doc/development/merge_request_performance_guidelines.md b/doc/development/merge_request_performance_guidelines.md index ec50b1557d4012e29f0d289b557da031247fc8c3..6552ed29e9804d172cde276d85a6365795f135ca 100644 --- a/doc/development/merge_request_performance_guidelines.md +++ b/doc/development/merge_request_performance_guidelines.md @@ -93,7 +93,7 @@ the following: The query plan can answer the questions whether we need additional indexes, or whether we perform expensive filtering (i.e. using sequential scans). -Each query plan should be run against substantional size of data set. +Each query plan should be run against substantial size of data set. For example if you look for issues with specific conditions, you should consider validating the query against a small number (a few hundred) and a big number (100_000) of issues. @@ -318,7 +318,7 @@ Take into consideration the following when choosing a pagination strategy: 1. It is very inefficient to calculate amount of objects that pass the filtering, this operation usually can take seconds, and can time out, -1. It is very inefficent to get entries for page at higher ordinals, like 1000. +1. It is very inefficient to get entries for page at higher ordinals, like 1000. The database has to sort and iterate all previous items, and this operation usually can result in substantial load put on database. @@ -363,7 +363,7 @@ The intent of quotas could be different: 1. We want to provide higher quotas for higher tiers of features: we want to provide on GitLab.com more capabilities for different tiers, -1. We want to prevent misuse of the feature: someone accidentially creates +1. We want to prevent misuse of the feature: someone accidentally creates 10000 deploy tokens, because of a broken API script, 1. We want to prevent abuse of the feature: someone purposely creates a 10000 pipelines to take advantage of the system. @@ -374,7 +374,7 @@ Examples: more than 50 schedules. In such cases it is rather expected that this is either misuse or abuse of the feature. Lack of the upper limit can result - in service degredation as the system will try to process all schedules + in service degradation as the system will try to process all schedules assigned the the project. 1. GitLab CI includes: We started with the limit of maximum of 50 nested includes. diff --git a/doc/development/migration_style_guide.md b/doc/development/migration_style_guide.md index 6301ba778bcf2e34d5518320fee07370a8a430c8..cccea4ee9f492867f82d7d4cebd645eb9b81b380 100644 --- a/doc/development/migration_style_guide.md +++ b/doc/development/migration_style_guide.md @@ -323,7 +323,7 @@ In this particular case, the default value exists and we're just changing the me in the `namespaces` table. Only when creating a new column with a default, all the records are going be rewritten. NOTE: **Note:** A faster [ALTER TABLE ADD COLUMN with a non-null default](https://www.depesz.com/2018/04/04/waiting-for-postgresql-11-fast-alter-table-add-column-with-a-non-null-default/) -was introduced on PostgresSQL 11.0, removing the need of rewritting the table when a new column with a default value is added. +was introduced on PostgresSQL 11.0, removing the need of rewriting the table when a new column with a default value is added. For the reasons mentioned above, it's safe to use `change_column_default` in a single-transaction migration without requiring `disable_ddl_transaction!`. diff --git a/doc/development/packages.md b/doc/development/packages.md index 7ae3cd53e660296d560495a67c10a5fe5bb75eb0..980c1869a0aea00d54de076441c1e15e85eddfc9 100644 --- a/doc/development/packages.md +++ b/doc/development/packages.md @@ -21,6 +21,7 @@ The goal of the Package group is to build a set of features that, within three y | Format | Use case | | ------ | ------ | | [Bower](https://gitlab.com/gitlab-org/gitlab/issues/36888) | Boost your front end development by hosting your own Bower components. | +| [Cargo](https://gitlab.com/gitlab-org/gitlab/issues/33060) | Cargo is the Rust package manager. Build, publish and share Rust packages | | [Chef](https://gitlab.com/gitlab-org/gitlab/issues/36889) | Configuration management with Chef using all the benefits of a repository manager. | | [CocoaPods](https://gitlab.com/gitlab-org/gitlab/issues/36890) | Speed up development with Xcode and CocoaPods. | | [Conda](https://gitlab.com/gitlab-org/gitlab/issues/36891) | Secure and private local Conda repositories. | @@ -110,7 +111,7 @@ File uploads should be handled by GitLab Workhorse using object accelerated uplo the workhorse proxy that checks all incoming requests to GitLab will intercept the upload request, upload the file, and forward a request to the main GitLab codebase only containing the metadata and file location rather than the file itself. An overview of this process can be found in the -[development documentation](uploads.md#workhorse-object-storage-acceleration). +[development documentation](uploads.md#direct-upload). In terms of code, this means a route will need to be added to the [GitLab Workhorse project](https://gitlab.com/gitlab-org/gitlab-workhorse) for each level of remote being added @@ -183,7 +184,7 @@ These changes represent all that is needed to deliver a minimally usable package 1. Empty file structure (API file, base service for this package) 1. Authentication system for 'logging in' to the package manager 1. Identify metadata and create applicable tables -1. Workhorse route for [object storage accelerated uploads](uploads.md#workhorse-object-storage-acceleration) +1. Workhorse route for [object storage direct upload](uploads.md#direct-upload) 1. Endpoints required for upload/publish 1. Endpoints required for install/download 1. Endpoints required for remove/delete diff --git a/doc/development/performance.md b/doc/development/performance.md index 786b590ec705f013f0aacfc17471b7683d444535..94285efdf1e0a1fba8912945e28f666d3a56541c 100644 --- a/doc/development/performance.md +++ b/doc/development/performance.md @@ -382,7 +382,7 @@ end ## String Freezing In recent Ruby versions calling `freeze` on a String leads to it being allocated -only once and re-used. For example, on Ruby 2.3 this will only allocate the +only once and re-used. For example, on Ruby 2.3 or later this will only allocate the "foo" String once: ```ruby diff --git a/doc/development/pipelines.md b/doc/development/pipelines.md index 99f92e6f39f14312d314cca1c4162983e9c5f4d3..7da74ae8b354aa769a786dcd1495833b42f33660 100644 --- a/doc/development/pipelines.md +++ b/doc/development/pipelines.md @@ -42,9 +42,9 @@ The current stages are: ## Default image The default image is currently -`registry.gitlab.com/gitlab-org/gitlab-build-images:ruby-2.6.3-golang-1.12-git-2.24-lfs-2.9-chrome-73.0-node-12.x-yarn-1.16-postgresql-9.6-graphicsmagick-1.3.33`. +`registry.gitlab.com/gitlab-org/gitlab-build-images:ruby-2.6.5-golang-1.12-git-2.24-lfs-2.9-chrome-73.0-node-12.x-yarn-1.16-postgresql-9.6-graphicsmagick-1.3.33`. -It includes Ruby 2.6.3, Go 1.12, Git 2.24, Git LFS 2.9, Chrome 73, Node 12, Yarn 1.16, +It includes Ruby 2.6.5, Go 1.12, Git 2.24, Git LFS 2.9, Chrome 73, Node 12, Yarn 1.16, PostgreSQL 9.6, and Graphics Magick 1.3.33. The images used in our pipelines are configured in the @@ -129,6 +129,64 @@ from a commit or MR by extending from the following CI definitions: **See <https://gitlab.com/gitlab-org/gitlab/blob/master/.gitlab/ci/global.gitlab-ci.yml> for the list of exact patterns.** +## Rules conditions and changes patterns + +We're making use of the [`rules` keyword](https://docs.gitlab.com/ee/ci/yaml/#rules) but we're currently +duplicating the `if` conditions and `changes` patterns lists since they cannot be shared across +`include`d files as we do with `extends`. + +**If you update an `if` condition or `changes` +patterns list, make sure to mass-update those across all the CI config files (i.e. `.gitlab/ci/*.yml`).** + +### Canonical commits only + +This condition limits jobs creation to commits under the `gitlab-org/` top-level group +on GitLab.com only. This is similar to the `.only:variables-canonical-dot-com` CI definition: + +```yaml +.if-canonical-gitlab: &if-canonical-gitlab + if: '$CI_SERVER_HOST == "gitlab.com" && $CI_PROJECT_NAMESPACE =~ /^gitlab-org($|\/)/' +``` + +### Canonical merge requests only + +Same as the "Canonical commits only" condition above but further limits jobs creation +to merge requests only (i.e. this won't run for `master`, stable or auto-deploy branches). +This is similar to the `.only:variables-canonical-dot-com` + `.except:refs-master-tags-stable-deploy` +CI definitions: + +```yaml +.if-canonical-gitlab-merge-request: &if-canonical-gitlab-merge-request + if: '$CI_SERVER_HOST == "gitlab.com" && $CI_PROJECT_NAMESPACE =~ /^gitlab-org($|\/)/ && $CI_MERGE_REQUEST_IID' +``` + +### Code changes patterns + +Similar patterns as for `.only:changes-code`: + +```yaml +.code-patterns: &code-patterns + - ... +``` + +### QA changes patterns + +Similar patterns as for `.only:changes-qa`: + +```yaml +.qa-patterns: &qa-patterns + - ... +``` + +### Code and QA changes patterns + +Similar patterns as for `.only:changes-code-qa`: + +```yaml +.code-qa-patterns: &code-qa-patterns + - ... +``` + ## Directed acyclic graph We're using the [`needs:`](../ci/yaml/README.md#needs) keyword to @@ -152,14 +210,14 @@ graph RL; M[coverage]; N[pages]; O[static-analysis]; - P["schedule:package-and-qa<br/>(master schedule only)"]; Q[package-and-qa]; - R[package-and-qa-manual]; S["RSpec<br/>(e.g. rspec unit pg9)"] T[retrieve-tests-metadata]; subgraph "`prepare` stage" A + B + C F K J @@ -167,8 +225,6 @@ subgraph "`prepare` stage" end subgraph "`test` stage" - B --> |needs| A; - C --> |needs| A; D --> |needs| A; H -.-> |needs and depends on| A; H -.-> |needs and depends on| K; @@ -201,10 +257,6 @@ subgraph "`review` stage" subgraph "`qa` stage" Q --> |needs| C; Q --> |needs| F; - R --> |needs| C; - R --> |needs| F; - P --> |needs| C; - P --> |needs| F; review-qa-smoke -.-> |needs and depends on| G; review-qa-all -.-> |needs and depends on| G; review-performance -.-> |needs and depends on| G; @@ -212,11 +264,6 @@ subgraph "`qa` stage" dast -.-> |needs and depends on| G; end -subgraph "`notification` stage" - NOTIFICATION1["schedule:package-and-qa:notify-success<br>(on_success)"] -.-> |needs| P; - NOTIFICATION2["schedule:package-and-qa:notify-failure<br>(on_failure)"] -.-> |needs| P; - end - subgraph "`post-test` stage" M end diff --git a/doc/development/policies.md b/doc/development/policies.md index 8e5ef6e57c02da8f4cf1c2a0eeced6db75644574..4d0454111562fc92cfe846f761147c01a5201ff1 100644 --- a/doc/development/policies.md +++ b/doc/development/policies.md @@ -95,8 +95,8 @@ Each line represents a rule that was evaluated. There are a few things to note: Here you can see that the first four rules were evaluated `false` for which user and subject. For example, you can see in the last line that -the rule was activated because the user `root` had at reporter access to -the `Project/4`. +the rule was activated because the user `root` had Reporter access to +`Project/4`. When a policy is asked whether a particular ability is allowed (`policy.allowed?(:some_ability)`), it does not necessarily have to diff --git a/doc/development/testing_guide/best_practices.md b/doc/development/testing_guide/best_practices.md index 356e3f7a2274081dd09c819bc2101706aff5dac6..a845b5a26e8a1f67d2df080d6880592dc0e391ff 100644 --- a/doc/development/testing_guide/best_practices.md +++ b/doc/development/testing_guide/best_practices.md @@ -67,9 +67,10 @@ When using spring and guard together, use `SPRING=1 bundle exec guard` instead t - Don't supply the `:each` argument to hooks since it's the default. - On `before` and `after` hooks, prefer it scoped to `:context` over `:all` - When using `evaluate_script("$('.js-foo').testSomething()")` (or `execute_script`) which acts on a given element, - use a Capyabara matcher beforehand (e.g. `find('.js-foo')`) to ensure the element actually exists. + use a Capybara matcher beforehand (e.g. `find('.js-foo')`) to ensure the element actually exists. - Use `focus: true` to isolate parts of the specs you want to run. - Use [`:aggregate_failures`](https://relishapp.com/rspec/rspec-core/docs/expectation-framework-integration/aggregating-failures) when there is more than one expectation in a test. +- For [empty test description blocks](https://github.com/rubocop-hq/rspec-style-guide#it-and-specify), use `specify` rather than `it do` if the test is self-explanatory. ### System / Feature tests diff --git a/doc/development/testing_guide/end_to_end/best_practices.md b/doc/development/testing_guide/end_to_end/best_practices.md index fc00fcea67e51daf9b8ca97b114d8fc2d792f74c..e060312e05fd117890fd9fd15e4fa31e42b64c3b 100644 --- a/doc/development/testing_guide/end_to_end/best_practices.md +++ b/doc/development/testing_guide/end_to_end/best_practices.md @@ -14,7 +14,7 @@ Now, realize that almost all tests need the user to be logged in, and that we ne Now, multiply the number of tests per 2 seconds, and as your test suite grows, the time to run it grows with it, and this is not sustainable. -An alternative to perform a login in a cheaper way would be having an endpoint (available only for testing) where we could pass the user's credentials as encrypted values as query strings, and then we would be redirected to the logged in home page if the credentials are valid. Let's say that, on average, this process takes only 200 miliseconds. +An alternative to perform a login in a cheaper way would be having an endpoint (available only for testing) where we could pass the user's credentials as encrypted values as query strings, and then we would be redirected to the logged in home page if the credentials are valid. Let's say that, on average, this process takes only 200 milliseconds. You see the point right? diff --git a/doc/development/testing_guide/end_to_end/index.md b/doc/development/testing_guide/end_to_end/index.md index 19885f5756f55ee2122de020b8d36434dfe1acc7..96141a5d68dfc083dbc950f0a1bb0e7bf83926db 100644 --- a/doc/development/testing_guide/end_to_end/index.md +++ b/doc/development/testing_guide/end_to_end/index.md @@ -15,27 +15,27 @@ a black-box testing framework for the API and the UI. ### Testing nightly builds -We run scheduled pipeline each night to test nightly builds created by Omnibus. +We run scheduled pipelines each night to test nightly builds created by Omnibus. You can find these nightly pipelines at `https://gitlab.com/gitlab-org/quality/nightly/pipelines` (need Developer access permissions). Results are reported in the `#qa-nightly` Slack channel. ### Testing staging -We run scheduled pipeline each night to test staging. +We run scheduled pipelines each night to test staging. You can find these nightly pipelines at `https://gitlab.com/gitlab-org/quality/staging/pipelines` -(need developer access permissions). Results are reported in the `#qa-staging` Slack channel. +(need Developer access permissions). Results are reported in the `#qa-staging` Slack channel. ### Testing code in merge requests -#### Using the `package-and-qa-manual` job +#### Using the `package-and-qa` job It is possible to run end-to-end tests for a merge request, eventually being run in a pipeline in the [`gitlab-qa`](https://gitlab.com/gitlab-org/gitlab-qa/) project, -by triggering the `package-and-qa-manual` manual action in the `test` stage (not +by triggering the `package-and-qa` manual action in the `test` stage (not available for forks). -**This runs end-to-end tests against a custom Omnibus package built from your -merge request's changes.** +**This runs end-to-end tests against a custom CE and EE (with an Ultimate license) +Omnibus package built from your merge request's changes.** Manual action that starts end-to-end tests is also available in merge requests in [Omnibus GitLab][omnibus-gitlab]. @@ -53,7 +53,7 @@ graph LR B2[`Trigger-qa` stage<br>`Trigger:qa-test` job] -.->|2. Triggers a gitlab-qa pipeline and wait for it to be done| A3 subgraph "gitlab-foss/gitlab pipeline" - A1[`test` stage<br>`package-and-qa-manual` job] + A1[`test` stage<br>`package-and-qa` job] end subgraph "omnibus-gitlab pipeline" @@ -61,7 +61,7 @@ subgraph "omnibus-gitlab pipeline" end subgraph "gitlab-qa pipeline" - A3>QA jobs run] -.->|3. Reports back the pipeline result to the `package-and-qa-manual` job<br>and post the result on the original commit tested| A1 + A3>QA jobs run] -.->|3. Reports back the pipeline result to the `package-and-qa` job<br>and post the result on the original commit tested| A1 end ``` diff --git a/doc/development/testing_guide/end_to_end/page_objects.md b/doc/development/testing_guide/end_to_end/page_objects.md index 554995fa2e22b37dd80f073cf6a5c2dc5503ed3e..f1e0de0c792b449ca6bf404d2790f3a66619658b 100644 --- a/doc/development/testing_guide/end_to_end/page_objects.md +++ b/doc/development/testing_guide/end_to_end/page_objects.md @@ -40,7 +40,7 @@ the time it would take to build packages and test everything. That is why when someone changes `t.text_field :login` to `t.text_field :username` in the _new session_ view we won't know about this change until our GitLab QA nightly pipeline fails, or until someone triggers -`package-and-qa-manual` action in their merge request. +`package-and-qa` action in their merge request. Obviously such a change would break all tests. We call this problem a _fragile tests problem_. @@ -171,8 +171,8 @@ and we should prefer the `data-qa-selector` method of definition. > Introduced in GitLab 12.5 -A common occurrence in automated testing is selecting a single "one-of-many" element. -In a list of several items, how do you differentiate what you are selecting on? +A common occurrence in automated testing is selecting a single "one-of-many" element. +In a list of several items, how do you differentiate what you are selecting on? The most common workaround for this is via text matching. Instead, a better practice is by matching on that specific element by a unique identifier, rather than by text. diff --git a/doc/development/testing_guide/end_to_end/quick_start_guide.md b/doc/development/testing_guide/end_to_end/quick_start_guide.md index fb820ac22a299cc0139d420880b22c05f658f503..be00129a2bc4793355803b2f0ce8e7ad2642b458 100644 --- a/doc/development/testing_guide/end_to_end/quick_start_guide.md +++ b/doc/development/testing_guide/end_to_end/quick_start_guide.md @@ -445,7 +445,7 @@ end By defining the `resource_web_url(resource)` method, we override the one from the [`ApiFabricator`](https://gitlab.com/gitlab-org/gitlab/blob/master/qa/qa/resource/api_fabricator.rb#L44) module. We do that to avoid failing the test due to this particular resource not exposing a `web_url` property. -By defining the `api_get_path` method, we **would** allow for the [`ApiFabricator`](https://gitlab.com/gitlab-org/gitlab/blob/master/qa/qa/resource/api_fabricator.rb) module to know which path to use to get a single label, but since there's no path available for that in the publich API, we raise a `NotImplementedError` instead. +By defining the `api_get_path` method, we **would** allow for the [`ApiFabricator`](https://gitlab.com/gitlab-org/gitlab/blob/master/qa/qa/resource/api_fabricator.rb) module to know which path to use to get a single label, but since there's no path available for that in the public API, we raise a `NotImplementedError` instead. By defining the `api_post_path` method, we allow for the [`ApiFabricator`](https://gitlab.com/gitlab-org/gitlab/blob/master/qa/qa/resource/api_fabricator.rb) module to know which path to use to create a new label in a specific project. diff --git a/doc/development/testing_guide/end_to_end/style_guide.md b/doc/development/testing_guide/end_to_end/style_guide.md index 9088e9e9bfbe13978e6c8326f81a1718d8d772fa..7f4616f394b23ddca765af9a419a0b49d5c642af 100644 --- a/doc/development/testing_guide/end_to_end/style_guide.md +++ b/doc/development/testing_guide/end_to_end/style_guide.md @@ -54,18 +54,20 @@ We follow a simple formula roughly based on hungarian notation. *Formula*: `element :<descriptor>_<type>` - `descriptor`: The natural-language description of what the element is. On the login page, this could be `username`, or `password`. -- `type`: A physical control on the page that can be seen by a user. +- `type`: A generic control on the page that can be seen by a user. - `_button` - - `_link` - - `_tab` - - `_dropdown` - - `_field` - `_checkbox` + - `_container`: an element that includes other elements, but doesn't present visible content itself. E.g., an element that has a third-party editor inside it, but which isn't the editor itself and so doesn't include the editor's content. + - `_content`: any element that contains text, images, or any other content displayed to the user. + - `_dropdown` + - `_field`: a text input element. + - `_link` + - `_modal`: a popup modal dialog, e.g., a confirmation prompt. + - `_placeholder`: a temporary element that appears while content is loading. For example, the elements that are displayed instead of discussions while the discussions are being fetched. - `_radio` - - `_content` + - `_tab` -*Note: This list is a work in progress. This list will eventually be the end-all enumeration of all available types. - I.e., any element that does not end with something in this list is bad form.* +*Note: If none of the listed types are suitable, please open a merge request to add an appropriate type to the list.* ### Examples diff --git a/doc/development/testing_guide/flaky_tests.md b/doc/development/testing_guide/flaky_tests.md index 3a96f8204fc8668bce2eb40b808726aee117b207..5628ca633f6bf02f8bc6a17531c36f1ddd920b88 100644 --- a/doc/development/testing_guide/flaky_tests.md +++ b/doc/development/testing_guide/flaky_tests.md @@ -76,7 +76,7 @@ This was originally implemented in: <https://gitlab.com/gitlab-org/gitlab-foss/m ### Feature tests -- [Be sure to create all the data the test need before starting exercize](https://gitlab.com/gitlab-org/gitlab-foss/issues/32622#note_31128195): <https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/12059> +- [Be sure to create all the data the test need before starting exercise](https://gitlab.com/gitlab-org/gitlab-foss/issues/32622#note_31128195): <https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/12059> - [Bis](https://gitlab.com/gitlab-org/gitlab-foss/issues/34609#note_34048715): <https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/12604> - [Bis](https://gitlab.com/gitlab-org/gitlab-foss/issues/34698#note_34276286): <https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/12664> - [Assert against the underlying database state instead of against a page's content](https://gitlab.com/gitlab-org/gitlab-foss/issues/31437): <https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/10934> @@ -98,6 +98,10 @@ This was originally implemented in: <https://gitlab.com/gitlab-org/gitlab-foss/m - Memory is through the roof! (TL;DR: Load images but block images requests!): <https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/12003> +#### Capybara expectation times out + +- [Test imports a project (via Sidekiq) that is growing over time, leading to timeouts when the import takes longer than 60 seconds](https://gitlab.com/gitlab-org/gitlab/merge_requests/22599) + ## Resources - [Flaky Tests: Are You Sure You Want to Rerun Them?](http://semaphoreci.com/blog/2017/04/20/flaky-tests.html) diff --git a/doc/development/testing_guide/frontend_testing.md b/doc/development/testing_guide/frontend_testing.md index 8934f0d4b656e15956b0401bceacd295790207e7..56b059063e0af72f0ab2af4563b9526921b6ac31 100644 --- a/doc/development/testing_guide/frontend_testing.md +++ b/doc/development/testing_guide/frontend_testing.md @@ -315,7 +315,7 @@ export, one is be generated by the babel plugin). The second parameter is the name of the import you wish to change. The result of the function is a Spy object which can be treated like any other Jasmine spy object. -Further documentation on the babel rewire pluign API can be found on +Further documentation on the babel rewire plugin API can be found on [its repository Readme doc](https://github.com/speedskater/babel-plugin-rewire#babel-plugin-rewire). #### Waiting in tests @@ -532,7 +532,7 @@ In order to ensure that a clean wrapper object and DOM are being used in each te }); ``` -See also the [Vue Test Utils documention on `destroy`](https://vue-test-utils.vuejs.org/api/wrapper/#destroy). +See also the [Vue Test Utils documentation on `destroy`](https://vue-test-utils.vuejs.org/api/wrapper/#destroy). #### Migrating flaky Karma tests to Jest @@ -649,7 +649,7 @@ it('uses some HTML element', () => { HTML and JSON fixtures are generated from backend views and controllers using RSpec (see `spec/frontend/fixtures/*.rb`). For each fixture, the content of the `response` variable is stored in the output file. -This variable gets automagically set if the test is marked as `type: :request` or `type: :controller`. +This variable gets automatically set if the test is marked as `type: :request` or `type: :controller`. Fixtures are regenerated using the `bin/rake frontend:fixtures` command but you can also generate them individually, for example `bin/rspec spec/frontend/fixtures/merge_requests.rb`. When creating a new fixture, it often makes sense to take a look at the corresponding tests for the endpoint in `(ee/)spec/controllers/` or `(ee/)spec/requests/`. diff --git a/doc/development/testing_guide/img/k9s.png b/doc/development/testing_guide/img/k9s.png index c4b222f0b6487ece7cbf60f661cba5ee57fcf279..34585b2a43a0d266f55b531c50a91885948a3663 100644 Binary files a/doc/development/testing_guide/img/k9s.png and b/doc/development/testing_guide/img/k9s.png differ diff --git a/doc/development/uploads.md b/doc/development/uploads.md index e3ff62f1d2f1a21be5e53306595f1c42d929e6dc..3eda06677536d043bbabf435e033bb910832463b 100644 --- a/doc/development/uploads.md +++ b/doc/development/uploads.md @@ -42,7 +42,7 @@ We have three challenges here: performance, availability, and scalability. Rails process are expensive in terms of both CPU and memory. Ruby [global interpreter lock](https://en.wikipedia.org/wiki/Global_interpreter_lock) adds to cost too because the ruby process will spend time on I/O operations on step 3 causing incoming requests to pile up. -In order to improve this, [workhorse disk acceleration](#workhorse-disk-acceleration) was implemented. With this, Rails no longer deals with writing uploaded files to disk. +In order to improve this, [disk buffered upload](#disk-buffered-upload) was implemented. With this, Rails no longer deals with writing uploaded files to disk. ```mermaid graph TB @@ -76,13 +76,13 @@ graph TB There's also an availability problem in this setup, NFS is a [single point of failure](https://en.wikipedia.org/wiki/Single_point_of_failure). -To address this problem an HA object storage can be used and it's supported by [workhorse object storage acceleration](#workhorse-object-storage-acceleration) +To address this problem an HA object storage can be used and it's supported by [direct upload](#direct-upload) ### Scalability Scaling NFS is outside of our support scope, and NFS is not a part of cloud native installations. -All features that require Sidekiq and do not use object storage acceleration won't work without NFS. In Kubernetes, machine boundaries translate to PODs, and in this case the uploaded file will be written into the POD private disk. Since Sidekiq POD cannot reach into other pods, the operation will fail to read it. +All features that require Sidekiq and do not use direct upload won't work without NFS. In Kubernetes, machine boundaries translate to PODs, and in this case the uploaded file will be written into the POD private disk. Since Sidekiq POD cannot reach into other pods, the operation will fail to read it. ## How to select the proper level of acceleration? @@ -90,9 +90,9 @@ Selecting the proper acceleration is a tradeoff between speed of development and We can identify three major use-cases for an upload: -1. **storage:** if we are uploading for storing a file (i.e. artifacts, packages, discussion attachments). In this case [object storage acceleration](#workhorse-object-storage-acceleration) is the proper level as it's the less resource-intensive operation. Additional information can be found on [File Storage in GitLab](file_storage.md). -1. **in-controller/synchronous processing:** if we allow processing **small files** synchronously, using [disk acceleration](#workhorse-disk-acceleration) may speed up development. -1. **Sidekiq/asynchronous processing:** Async processing must implement [object storage acceleration](#workhorse-object-storage-acceleration), the reason being that it's the only way to support Cloud Native deployments without a shared NFS. +1. **storage:** if we are uploading for storing a file (i.e. artifacts, packages, discussion attachments). In this case [direct upload](#direct-upload) is the proper level as it's the less resource-intensive operation. Additional information can be found on [File Storage in GitLab](file_storage.md). +1. **in-controller/synchronous processing:** if we allow processing **small files** synchronously, using [disk buffered upload](#disk-buffered-upload) may speed up development. +1. **Sidekiq/asynchronous processing:** Async processing must implement [direct upload](#direct-upload), the reason being that it's the only way to support Cloud Native deployments without a shared NFS. For more details about currently broken feature see [epic &1802](https://gitlab.com/groups/gitlab-org/-/epics/1802). @@ -122,7 +122,7 @@ By uploading technologies we mean how all the involved services interact with ea GitLab supports 3 kinds of uploading technologies, here follows a brief description with a sequence diagram for each one. Diagrams are not meant to be exhaustive. -### Regular rails upload +### Rack Multipart upload This is the default kind of upload, and it's most expensive in terms of resources. @@ -148,7 +148,7 @@ sequenceDiagram deactivate w ``` -### Workhorse disk acceleration +### Disk buffered upload This kind of upload avoids wasting resources caused by handling upload writes to `/tmp` in rails. @@ -202,7 +202,7 @@ sequenceDiagram end ``` -### Workhorse object storage acceleration +### Direct upload This is the more advanced acceleration technique we have in place. @@ -212,9 +212,8 @@ In this setup an extra rails route needs to be implemented in order to handle au you can see an example of this in [`Projects::LfsStorageController`](https://gitlab.com/gitlab-org/gitlab/blob/cc723071ad337573e0360a879cbf99bc4fb7adb9/app/controllers/projects/lfs_storage_controller.rb) and [its routes](https://gitlab.com/gitlab-org/gitlab/blob/cc723071ad337573e0360a879cbf99bc4fb7adb9/config/routes/git_http.rb#L31-32). -NOTE: **Note:** -This will fall back to _Workhorse disk acceleration_ when object storage is not enabled -in the GitLab instance. The answer to the `/authorize` call will only contain a file system path. +**note:** this will fallback to _disk buffered upload_ when `direct_upload` is disabled inside the [object storage setting](../administration/uploads.md#object-storage-settings). +The answer to the `/authorize` call will only contain a file system path. ```mermaid sequenceDiagram @@ -262,11 +261,3 @@ sequenceDiagram deactivate sidekiq end ``` - -## What does the `direct_upload` setting mean? - -[Object storage setting](../administration/uploads.md#object-storage-settings) allows instance administators to enable `direct_upload`, this in an option that only affects the behavior of [workhorse object storage acceleration](#workhorse-object-storage-acceleration). - -This option affect the response to the `/authorize` call. When not enabled, the API response will not contain presigned URLs and workhorse will write the file the shared disk, on the path is provided by rails, acting like object storage was disabled. - -Once the request reachs rails, it will schedule an object storage upload as a Sidekiq job. diff --git a/doc/development/what_requires_downtime.md b/doc/development/what_requires_downtime.md index 9e43758a4aabb2c132261896e2512307fae361d1..356feae4eafa650e757bb7c80f2e646c62d9f93f 100644 --- a/doc/development/what_requires_downtime.md +++ b/doc/development/what_requires_downtime.md @@ -34,6 +34,9 @@ blocking access to the table being modified. See ["Adding Columns With Default Values"](migration_style_guide.md#adding-columns-with-default-values) for more information on how to use this method. +Note that usage of `add_column_with_default` with `allow_null: false` to also add +a `NOT NULL` constraint is [discouraged](https://gitlab.com/gitlab-org/gitlab/issues/38060). + ## Dropping Columns Removing columns is tricky because running GitLab processes may still be using diff --git a/doc/gitlab-basics/add-merge-request.md b/doc/gitlab-basics/add-merge-request.md index 28f32fefb957fed058c55cf5ee71fba15380604a..1ee28183ac84715c2971cd27ff422eea4a31dd45 100644 --- a/doc/gitlab-basics/add-merge-request.md +++ b/doc/gitlab-basics/add-merge-request.md @@ -1,47 +1,5 @@ --- -type: howto +redirect_to: '../user/project/merge_requests/creating_merge_requests.md' --- -# How to create a merge request - -Merge requests are how you integrate separate changes that you've made in a -[branch](create-branch.md) to a [project](create-project.md). - -This is a brief guide on how to create a merge request. For more detailed information, -check the [merge requests documentation](../user/project/merge_requests/index.md), or -you can watch our [GitLab Flow video](https://www.youtube.com/watch?v=InKNIvky2KE) for -a quick overview of working with merge requests. - -1. Before you start, you should have already [created a branch](create-branch.md) - and [pushed your changes](start-using-git.md#send-changes-to-gitlabcom) to GitLab. -1. Go to the project where you'd like to merge your changes and click on the - **Merge requests** tab. -1. Click on **New merge request** on the right side of the screen. -1. From there, you have the option to select the source branch and the target - branch you'd like to compare to. The default target project is the upstream - repository, but you can choose to compare across any of its forks. - -  - -1. When ready, click on the **Compare branches and continue** button. -1. At a minimum, add a title and a description to your merge request. Optionally, - select a user to review your merge request. You may also select a milestone and - labels. - -  - -1. When ready, click on the **Submit merge request** button. - -Your merge request will be ready to be reviewed, approved, and merged. - -<!-- ## Troubleshooting - -Include any troubleshooting steps that you can foresee. If you know beforehand what issues -one might have when setting this up, or when something is changed, or on upgrading, it's -important to describe those, too. Think of things that may go wrong and include them here. -This is important to minimize requests for support, and to avoid doc comments with -questions that you know someone might ask. - -Each scenario can be a third-level heading, e.g. `### Getting error message X`. -If you have none to add when creating a doc, leave this section in place -but commented out to help encourage others to add to it in the future. --> +This document was moved to [another location](../user/project/merge_requests/creating_merge_requests.md). diff --git a/doc/gitlab-basics/create-project.md b/doc/gitlab-basics/create-project.md index 8edce515ec8001e8aaad5c1c873f502cb1656ffe..74b3afbcd986fc7b7aee1f416a1350931fc59c21 100644 --- a/doc/gitlab-basics/create-project.md +++ b/doc/gitlab-basics/create-project.md @@ -31,7 +31,14 @@ To create a new blank project on the **New project** page: 1. On the **Blank project** tab, provide the following information: - The name of your project in the **Project name** field. You can't use special characters, but you can use spaces, hyphens, underscores or even - emoji. + emoji. When adding the name, the **Project slug** will auto populate. + The slug is what the GitLab instance will use as the URL path to the project. + If you want a different slug, input the project name first, + then change the slug after. + - The path to your project in the **Project slug** field. This is the URL + path for your project that the GitLab instance will use. If the + **Project name** is blank, it will auto populate when you fill in + the **Project slug**. - The **Project description (optional)** field enables you to enter a description for your project's dashboard, which will help others understand what your project is about. Though it's not required, it's a good diff --git a/doc/gitlab-basics/start-using-git.md b/doc/gitlab-basics/start-using-git.md index 097794d39a7fa0445ec143d4c715dc6b6e520247..7fa84bf45bd01049226634da1a9fd8fab0268546 100644 --- a/doc/gitlab-basics/start-using-git.md +++ b/doc/gitlab-basics/start-using-git.md @@ -313,7 +313,7 @@ git merge master ### Synchronize changes in a forked repository with the upstream -[Forking a repository](../user/project/repository/forking_workflow.md lets you create +[Forking a repository](../user/project/repository/forking_workflow.md) lets you create a copy of a repository in your namespace. Changes made to your copy of the repository are not synchronized automatically with the original. Your local fork (copy) contains changes made by you only, so to keep the project diff --git a/doc/install/README.md b/doc/install/README.md index 441826687aac5b43c1483d52f65cc182b09c7f46..6b497763d936c3bddf9944b44015ee47a894fc2b 100644 --- a/doc/install/README.md +++ b/doc/install/README.md @@ -87,3 +87,7 @@ the above methods, provided the cloud provider supports it. - [Install GitLab on DigitalOcean](https://about.gitlab.com/blog/2016/04/27/getting-started-with-gitlab-and-digitalocean/): Install Omnibus GitLab on DigitalOcean. - _Testing only!_ [DigitalOcean and Docker Machine](digitaloceandocker.md): Quickly test any version of GitLab on DigitalOcean using Docker Machine. + +## Securing your GitLab installation + +After completing your installation, check out our [recommended practices to secure your GitLab instance](../security/README.md#securing-your-gitlab-installation). diff --git a/doc/install/aws/index.md b/doc/install/aws/index.md index 8165d3edabbf6ac8bdd6565de54bc9fa311fe703..a3b9124a2baa123c278d87a29e35a510c09e2404 100644 --- a/doc/install/aws/index.md +++ b/doc/install/aws/index.md @@ -211,7 +211,7 @@ create the actual RDS instance. Now, it's time to create the database: -1. Select **Instances** from the left menu and click **Create database**. +1. Select **Databases** from the left menu and click **Create database**. 1. Select PostgreSQL and click **Next**. 1. Since this is a production server, let's choose "Production". Click **Next**. 1. Let's see the instance specifications: @@ -244,7 +244,7 @@ Once the database is created, connect to your new RDS instance to verify access and to install a required extension. You can find the host or endpoint by selecting the instance you just created and -after the details drop down you'll find it labeled as 'Endpoint'. Do not to +after the details dropdown menu you'll find it labeled as 'Endpoint'. Do not to include the colon and port number: ```sh diff --git a/doc/install/azure/index.md b/doc/install/azure/index.md index c789467175a70542ba2a977ce6f32c95d65bed13..5baaec79048e649cc1b53e99b7c8d8b2ff96ee4e 100644 --- a/doc/install/azure/index.md +++ b/doc/install/azure/index.md @@ -226,7 +226,7 @@ connections:  1. Enter **"HTTP"** in the `Name` field -1. Select **HTTP** from the options in the `Service` drop-down +1. Select **HTTP** from the options in the `Service` dropdown list 1. Make sure the `Action` is set to **Allow** 1. Click **"OK"** @@ -238,7 +238,7 @@ accept [SSH] connections:  1. Enter **"SSH"** in the `Name` field -1. Select **SSH** from the options in the `Service` drop-down +1. Select **SSH** from the options in the `Service` dropdown list 1. Make sure the `Action` is set to **Allow** 1. Click **"OK"** diff --git a/doc/install/installation.md b/doc/install/installation.md index d420ac5e952d1e4497a2c92a80ca3ae27f5ba8d4..5cd8f98b2f311febd4af59df7dd525fa6d7f6b47 100644 --- a/doc/install/installation.md +++ b/doc/install/installation.md @@ -224,9 +224,9 @@ Download Ruby and compile it: ```sh mkdir /tmp/ruby && cd /tmp/ruby -curl --remote-name --progress https://cache.ruby-lang.org/pub/ruby/2.6/ruby-2.6.3.tar.gz -echo '2347ed6ca5490a104ebd5684d2b9b5eefa6cd33c ruby-2.6.3.tar.gz' | shasum -c - && tar xzf ruby-2.6.3.tar.gz -cd ruby-2.6.3 +curl --remote-name --progress https://cache.ruby-lang.org/pub/ruby/2.6/ruby-2.6.5.tar.gz +echo '1416ce288fb8bfeae07a12b608540318c9cace71 ruby-2.6.5.tar.gz' | shasum -c - && tar xzf ruby-2.6.5.tar.gz +cd ruby-2.6.5 ./configure --disable-install-rdoc make @@ -250,11 +250,11 @@ page](https://golang.org/dl). # Remove former Go installation folder sudo rm -rf /usr/local/go -curl --remote-name --progress https://dl.google.com/go/go1.11.10.linux-amd64.tar.gz -echo 'aefaa228b68641e266d1f23f1d95dba33f17552ba132878b65bb798ffa37e6d0 go1.11.10.linux-amd64.tar.gz' | shasum -a256 -c - && \ - sudo tar -C /usr/local -xzf go1.11.10.linux-amd64.tar.gz +curl --remote-name --progress https://dl.google.com/go/go1.13.5.linux-amd64.tar.gz +echo '512103d7ad296467814a6e3f635631bd35574cab3369a97a323c9a585ccaa569 go1.13.5.linux-amd64.tar.gz' | shasum -a256 -c - && \ + sudo tar -C /usr/local -xzf go1.13.5.linux-amd64.tar.gz sudo ln -sf /usr/local/go/bin/{go,godoc,gofmt} /usr/local/bin/ -rm go1.11.10.linux-amd64.tar.gz +rm go1.13.5.linux-amd64.tar.gz ``` ## 4. Node diff --git a/doc/install/requirements.md b/doc/install/requirements.md index 106c7714bfe7dac5277982277915184444afeef2..9bc1658d59c4278b08fb19f84236b488cfb332a5 100644 --- a/doc/install/requirements.md +++ b/doc/install/requirements.md @@ -38,14 +38,40 @@ GitLab is developed for Linux-based operating systems. It does **not** run on Microsoft Windows, and we have no plans to support it in the near future. For the latest development status view this [issue](https://gitlab.com/gitlab-org/gitlab/issues/22337). Please consider using a virtual machine to run GitLab. -## Ruby versions +## Software requirements -GitLab requires Ruby (MRI) 2.6. Support for Ruby versions below 2.6 (2.4, 2.5) will stop with GitLab 12.2. +### Ruby versions -You will have to use the standard MRI implementation of Ruby. -We love [JRuby](https://www.jruby.org/) and [Rubinius](https://rubinius.com) but GitLab +GitLab requires Ruby (MRI) 2.6. Beginning in GitLab 12.2, we no longer support Ruby 2.5 and lower. + +You must use the standard MRI implementation of Ruby. +We love [JRuby](https://www.jruby.org/) and [Rubinius](https://rubinius.com), but GitLab needs several Gems that have native extensions. +### Go versions + +The minimum required Go version is 1.12. + +### Git versions + +GitLab 11.11 and higher only supports Git 2.21.x and newer, and +[dropped support for older versions](https://gitlab.com/gitlab-org/gitlab-foss/issues/54255). + +### Node.js versions + +Beginning in GitLab 11.8, we only support Node.js 8.10.0 or higher, and dropped +support for Node.js 6. + +We recommend Node 12.x, as it is faster. + +GitLab uses [webpack](https://webpack.js.org/) to compile frontend assets, which requires a minimum +version of Node.js 8.10.0. + +You can check which version you are running with `node -v`. If you are running +a version older than `v8.10.0`, you need to update to a newer version. You +can find instructions to install from community maintained packages or compile +from source at the [Node.js website](https://nodejs.org/en/download). + ## Hardware requirements ### Storage @@ -210,7 +236,7 @@ For reference, GitLab.com's [auto-scaling shared runner](../user/gitlab_com/inde ## Supported web browsers -We support the current and the previous major release of: +GitLab supports the following web browsers: - Firefox - Chrome/Chromium @@ -218,10 +244,11 @@ We support the current and the previous major release of: - Microsoft Edge - Internet Explorer 11 -The browser vendors release regular minor version updates with important bug fixes and security updates. -Support is only provided for the current minor version of the major version you are running. +For the listed web browsers, GitLab supports: -Each time a new browser version is released, we begin supporting that version and stop supporting the third most recent version. +- The current and previous major versions of browsers except Internet Explorer. +- Only version 11 of Internet Explorer. +- The current minor version of a supported major version. NOTE: **Note:** We do not support running GitLab with JavaScript disabled in the browser and have no plans of supporting that in the future because we have features such as Issue Boards which require JavaScript extensively. diff --git a/doc/integration/elasticsearch.md b/doc/integration/elasticsearch.md index 62b3de72a3ae50b8cd0ee831d8730128386d0861..44f32343151e68bf9924a84344ea6b779158bd02 100644 --- a/doc/integration/elasticsearch.md +++ b/doc/integration/elasticsearch.md @@ -17,9 +17,10 @@ special searches: | GitLab version | Elasticsearch version | | -------------- | --------------------- | -| GitLab Enterprise Edition 8.4 - 8.17 | Elasticsearch 2.4 with [Delete By Query Plugin](https://www.elastic.co/guide/en/elasticsearch/plugins/2.4/plugins-delete-by-query.html) installed | +| GitLab Enterprise Edition 8.4 - 8.17 | Elasticsearch 2.4 with [Delete By Query Plugin](https://www.elastic.co/guide/en/elasticsearch/plugins/2.4/plugins-delete-by-query.html) installed | | GitLab Enterprise Edition 9.0 - 11.4 | Elasticsearch 5.1 - 5.5 | -| GitLab Enterprise Edition 11.5+ | Elasticsearch 5.6 - 6.x | +| GitLab Enterprise Edition 11.5 - 12.6 | Elasticsearch 5.6 - 6.x | +| GitLab Enterprise Edition 12.7+ | Elasticsearch 6.x - 7.x | ## Installing Elasticsearch @@ -36,18 +37,20 @@ it yourself or by using the service. Running Elasticsearch on the same server as GitLab is not recommended and it will likely cause performance degradation on the GitLab installation. +NOTE: **Note:** +**For a single node Elasticsearch cluster the functional cluster health status will be yellow** (will never be green) because the primary shard is allocated but replicas can not be as there is no other node to which Elasticsearch can assign a replica. + Once the data is added to the database or repository and [Elasticsearch is -enabled in the admin area](#enabling-elasticsearch) the search index will be +enabled in the Admin Area](#enabling-elasticsearch) the search index will be updated automatically. -## Elasticsearch repository indexer (beta) +## Elasticsearch repository indexer -In order to improve Elasticsearch indexing performance, GitLab has made available a [new indexer written in Go](https://gitlab.com/gitlab-org/gitlab-elasticsearch-indexer). -This will replace the included Ruby indexer in the future but should be considered beta software for now, so there may be some bugs. +For indexing Git repository data, GitLab uses an [indexer written in Go](https://gitlab.com/gitlab-org/gitlab-elasticsearch-indexer). -The Elasticsearch Go indexer is included in Omnibus for GitLab 11.8 and newer. - -To use the new Elasticsearch indexer included in Omnibus, check the box "Use the new repository indexer (beta)" when [enabling the Elasticsearch integration](#enabling-elasticsearch). +The Go indexer was included in Omnibus GitLab 11.8 as an optional replacement to a +Ruby-based indexer. [Since GitLab v12.3](https://gitlab.com/gitlab-org/gitlab/issues/6481), +all indexing is done by the Go indexer, and the Ruby indexer is removed. If you would like to use the Elasticsearch Go indexer with a source installation or an older version of GitLab, please follow the instructions below. @@ -139,7 +142,6 @@ The following Elasticsearch settings are available: | Parameter | Description | | ----------------------------------------------------- | ----------- | | `Elasticsearch indexing` | Enables/disables Elasticsearch indexing. You may want to enable indexing but disable search in order to give the index time to be fully completed, for example. Also, keep in mind that this option doesn't have any impact on existing data, this only enables/disables background indexer which tracks data changes. So by enabling this you will not get your existing data indexed, use special rake task for that as explained in [Adding GitLab's data to the Elasticsearch index](#adding-gitlabs-data-to-the-elasticsearch-index). | -| `Use the new repository indexer (beta)` | Perform repository indexing using [GitLab Elasticsearch Indexer](https://gitlab.com/gitlab-org/gitlab-elasticsearch-indexer). | | `Search with Elasticsearch enabled` | Enables/disables using Elasticsearch in search. | | `URL` | The URL to use for connecting to Elasticsearch. Use a comma-separated list to support clustering (e.g., `http://host1, https://host2:9200`). If your Elasticsearch instance is password protected, pass the `username:password` in the URL (e.g., `http://<username>:<password>@<elastic_host>:9200/`). | | `Number of Elasticsearch shards` | Elasticsearch indexes are split into multiple shards for performance reasons. In general, larger indexes need to have more shards. Changes to this value do not take effect until the index is recreated. You can read more about tradeoffs in the [Elasticsearch documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-create-index.html#create-index-settings) | @@ -200,10 +202,9 @@ To backfill existing data, you can use one of the methods below to index it in b > [Introduced](https://gitlab.com/gitlab-org/gitlab/merge_requests/15390) in [GitLab Starter](https://about.gitlab.com/pricing/) 12.3. -To index via the admin area: +To index via the Admin Area: -1. Navigate to the **Admin Area** (wrench icon), then **Settings > Integrations** and expand the **Elasticsearch** section. -1. [Enable **Elasticsearch indexing** and configure your host and port](#enabling-elasticsearch). +1. [Configure your Elasticsearch host and port](#enabling-elasticsearch). 1. Create empty indexes using one of the following commands: ```sh @@ -214,7 +215,8 @@ To index via the admin area: bundle exec rake gitlab:elastic:create_empty_index RAILS_ENV=production ``` -1. Click **Index all projects**. +1. [Enable **Elasticsearch indexing**](#enabling-elasticsearch). +1. Click **Index all projects** in **Admin Area > Settings > Integrations > Elasticsearch**. 1. Click **Check progress** in the confirmation message to see the status of the background jobs. 1. Personal snippets need to be indexed manually by running one of these commands: @@ -257,7 +259,7 @@ Performing asynchronous indexing will generate a lot of Sidekiq jobs. Make sure to prepare for this task by either [Horizontally Scaling](../administration/high_availability/README.md#basic-scaling) or creating [extra Sidekiq processes](../administration/operations/extra_sidekiq_processes.md) -1. [Enable **Elasticsearch indexing** and configure your host and port](#enabling-elasticsearch). +1. [Configure your Elasticsearch host and port](#enabling-elasticsearch). 1. Create empty indexes using one of the following commands: ```sh @@ -268,6 +270,7 @@ or creating [extra Sidekiq processes](../administration/operations/extra_sidekiq bundle exec rake gitlab:elastic:create_empty_index RAILS_ENV=production ``` +1. [Enable **Elasticsearch indexing**](#enabling-elasticsearch). 1. Indexing large Git repositories can take a while. To speed up the process, you can temporarily disable auto-refreshing and replicating. In our experience, you can expect a 20% decrease in indexing time. We'll enable them when indexing is done. This step is optional! @@ -592,6 +595,23 @@ Here are some common pitfalls and how to overcome them: AWS has [fixed limits](https://docs.aws.amazon.com/elasticsearch-service/latest/developerguide/aes-limits.html) for this setting ("Maximum Size of HTTP Request Payloads"), based on the size of the underlying instance. + +- **My single node Elasticsearch cluster status never goes from `yellow` to `green` even though everything seems to be running properly** + + **For a single node Elasticsearch cluster the functional cluster health status will be yellow** (will never be green) because the primary shard is allocated but replicas can not be as there is no other node to which Elasticsearch can assign a replica. This also applies if you are using using the +[Amazon Elasticsearch](https://docs.aws.amazon.com/elasticsearch-service/latest/developerguide/aes-handling-errors.html#aes-handling-errors-yellow-cluster-status) service. + + CAUTION: **Warning**: Setting the number of replicas to `0` is not something that we recommend (this is not allowed in the GitLab Elasticsearch Integration menu). If you are planning to add more Elasticsearch nodes (for a total of more than 1 Elasticsearch) the number of replicas will need to be set to an integer value larger than `0`. Failure to do so will result in lack of redundancy (losing one node will corrupt the index). + + If you have a **hard requirement to have a green status for your single node Elasticsearch cluster**, please make sure you understand the risks outlined in the previous paragraph and then simply run the following query to set the number of replicas to `0`(the cluster will no longer try to create any shard replicas): + + ```bash + curl --request PUT localhost:9200/gitlab-production/_settings --header 'Content-Type: application/json' --data '{ + "index" : { + "number_of_replicas" : 0 + } + }' + ``` ### Reverting to basic search diff --git a/doc/integration/img/authorize_vault_with_gitlab_v12_6.png b/doc/integration/img/authorize_vault_with_gitlab_v12_6.png index dc5bc954cd739a4c718875bd1c975d4e3d283f24..2c9810bc9e804b51f8ed5fe4c5025b37bf3bde1b 100644 Binary files a/doc/integration/img/authorize_vault_with_gitlab_v12_6.png and b/doc/integration/img/authorize_vault_with_gitlab_v12_6.png differ diff --git a/doc/integration/img/gitlab_oauth_vault_v12_6.png b/doc/integration/img/gitlab_oauth_vault_v12_6.png index f952abc2c6d9ea3e46e616c8e35d7bfdc4473bd7..08fc56c8ec9fcd944c0820667bc174f095c6df99 100644 Binary files a/doc/integration/img/gitlab_oauth_vault_v12_6.png and b/doc/integration/img/gitlab_oauth_vault_v12_6.png differ diff --git a/doc/integration/img/sign_into_vault_with_gitlab_v12_6.png b/doc/integration/img/sign_into_vault_with_gitlab_v12_6.png index 8afa2c6aabdbcaaa1fe9cef2d47e56e24318b46e..474473e334d677ef0a272ac9dbd5b36b9a5a52f8 100644 Binary files a/doc/integration/img/sign_into_vault_with_gitlab_v12_6.png and b/doc/integration/img/sign_into_vault_with_gitlab_v12_6.png differ diff --git a/doc/integration/img/signed_into_vault_via_oidc_v12_6.png b/doc/integration/img/signed_into_vault_via_oidc_v12_6.png index 0ad81ef40e68831d35f5866d55897ceea3cae081..0a7f86ff5f0027b5dfedb315b0fe63d85e53c8b6 100644 Binary files a/doc/integration/img/signed_into_vault_via_oidc_v12_6.png and b/doc/integration/img/signed_into_vault_via_oidc_v12_6.png differ diff --git a/doc/integration/img/sourcegraph_admin_v12_5.png b/doc/integration/img/sourcegraph_admin_v12_5.png index 23e38f566193f206c2d547521bdeb7f10295a7e5..54511541c87c6a6e49cef1def9a2aa2bcd63daa7 100644 Binary files a/doc/integration/img/sourcegraph_admin_v12_5.png and b/doc/integration/img/sourcegraph_admin_v12_5.png differ diff --git a/doc/integration/img/sourcegraph_demo_v12_5.png b/doc/integration/img/sourcegraph_demo_v12_5.png index c70448c0a8a479f7be409f2f9886be5bad04115e..35a2e2a89e3577bb2d0b1515b8b864227103b677 100644 Binary files a/doc/integration/img/sourcegraph_demo_v12_5.png and b/doc/integration/img/sourcegraph_demo_v12_5.png differ diff --git a/doc/integration/img/sourcegraph_popover_v12_5.png b/doc/integration/img/sourcegraph_popover_v12_5.png index 878d61436464e87c2584933d440be6070ebef698..62fa48129b25598682b39cdcad0abf6fd16a67d4 100644 Binary files a/doc/integration/img/sourcegraph_popover_v12_5.png and b/doc/integration/img/sourcegraph_popover_v12_5.png differ diff --git a/doc/integration/img/sourcegraph_user_preferences_v12_5.png b/doc/integration/img/sourcegraph_user_preferences_v12_5.png index 2c0e138e296c17c248f163bb6c45e8cbedd8e3ee..7b7f894543178cff853e7885c7f36686ce46458a 100644 Binary files a/doc/integration/img/sourcegraph_user_preferences_v12_5.png and b/doc/integration/img/sourcegraph_user_preferences_v12_5.png differ diff --git a/doc/integration/jira_development_panel.md b/doc/integration/jira_development_panel.md index a3a66cf6cf2158856ad108a7271217179001a976..02cfdc32abb85a19bf28456bb96412c8d3a226ff 100644 --- a/doc/integration/jira_development_panel.md +++ b/doc/integration/jira_development_panel.md @@ -100,7 +100,7 @@ There are no special requirements if you are using GitLab.com. every 60 minutes. > **Note:** - > In the future, we plan on implementating real-time integration. If you need + > In the future, we plan on implementing real-time integration. If you need > to refresh the data manually, you can do this from the `Applications -> DVCS > accounts` screen where you initially set up the integration: > diff --git a/doc/integration/kerberos.md b/doc/integration/kerberos.md index 2a3e2e43d729166444fbc4f25327a1af011d39d9..1cdbdb1c40e1c39f1f5a66bdef89df73f5caab01 100644 --- a/doc/integration/kerberos.md +++ b/doc/integration/kerberos.md @@ -265,7 +265,7 @@ so the client will fall back to attempting to negotiate `IAKERB`, leading to the above error message. To fix this, ensure that the forward and reverse DNS for your GitLab server -match. So for instance, if you acces GitLab as `gitlab.example.com`, resolving +match. So for instance, if you access GitLab as `gitlab.example.com`, resolving to IP address `1.2.3.4`, then `4.3.2.1.in-addr.arpa` must be a PTR record for `gitlab.example.com`. diff --git a/doc/integration/oauth_provider.md b/doc/integration/oauth_provider.md index 36b4836e6b3c4e44d6ee9e0ba5d6271d57706998..6c9b272f35bfe232ff93bb79115afb8179018eb7 100644 --- a/doc/integration/oauth_provider.md +++ b/doc/integration/oauth_provider.md @@ -26,7 +26,7 @@ The 'GitLab Importer' feature is also using the OAuth protocol to give access to repositories without sharing user credentials to your GitLab.com account. GitLab supports two ways of adding a new OAuth2 application to an instance. You -can either add an application as a regular user or add it in the admin area. +can either add an application as a regular user or add it in the Admin Area. What this means is that GitLab can actually have instance-wide and a user-wide applications. There is no difference between them except for the different permission levels they are set (user/admin). The default callback URL is @@ -51,14 +51,14 @@ connects to GitLab.  -## OAuth applications in the admin area +## OAuth applications in the Admin Area To create an application that does not belong to a certain user, you can create -it from the admin area. +it from the Admin Area.  -You're also able to mark an application as _trusted_ when creating it through the admin area. By doing that, +You're also able to mark an application as _trusted_ when creating it through the Admin Area. By doing that, the user authorization step is automatically skipped for this application. ## Authorized applications diff --git a/doc/integration/saml.md b/doc/integration/saml.md index a667c2e84c9384ec5df7115665c0d6adcc595243..11e768194bcb2f243668e2b2ae23f9424aa1d9c6 100644 --- a/doc/integration/saml.md +++ b/doc/integration/saml.md @@ -155,7 +155,7 @@ Identity Provider. ### Requirements First you need to tell GitLab where to look for group information. For this you -need to make sure that your IdP server sends a specific `AttributeStament` along +need to make sure that your IdP server sends a specific `AttributeStatement` along with the regular SAML response. Here is an example: ```xml diff --git a/doc/integration/sourcegraph.md b/doc/integration/sourcegraph.md index 358657ca1727c711ff5a71fd6ef7888a5f388911..25d1ef457c083270076adf7089534a754445acde 100644 --- a/doc/integration/sourcegraph.md +++ b/doc/integration/sourcegraph.md @@ -100,7 +100,7 @@ When visiting one of these views, you can now hover over a code reference to see - Details on how this reference was defined. - **Go to definition**, which navigates to the line of code where this reference was defined. -- **Find references**, which navigates to the configured Sourcegraph instance, showing a list of references to the hilighted code. +- **Find references**, which navigates to the configured Sourcegraph instance, showing a list of references to the highlighted code.  diff --git a/doc/integration/vault.md b/doc/integration/vault.md index 68803fed35d79b622e018bd6f58a4b5ed83330f3..8bd0897548aef670daa475ecfc64e0735a9ece20 100644 --- a/doc/integration/vault.md +++ b/doc/integration/vault.md @@ -7,7 +7,7 @@ type: reference, howto > [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/issues/22323) in GitLab 9.0 [Vault](https://www.vaultproject.io/) is a secrets management application offered by HashiCorp. -It allows you to store and manage sensitive information such secret environment variables, encryption keys, and authentication tokens. +It allows you to store and manage sensitive information such as secret environment variables, encryption keys, and authentication tokens. Vault offers Identity-based Access, which means Vault users can authenticate through several of their preferred cloud providers. In this document, we'll explain how Vault users can authenticate themselves through GitLab by utilizing our OpenID authentication feature. diff --git a/doc/policy/maintenance.md b/doc/policy/maintenance.md index 7617d0c8881f840dcfd18713a71d044800a3cbcf..1739c07ccd5ec30c933e5260c9808c54a3afeba9 100644 --- a/doc/policy/maintenance.md +++ b/doc/policy/maintenance.md @@ -7,6 +7,11 @@ type: concepts GitLab has strict policies governing version naming, as well as release pace for major, minor, patch and security releases. New releases are usually announced on the [GitLab blog](https://about.gitlab.com/blog/categories/releases/). +Our current policy is: + +- Backporting bug fixes for **only the current stable release** at any given time, see [patch releases](#patch-releases). +- Backporting to **to the previous two monthly releases in addition to the current stable release**, see [security releases](#security-releases). + ## Versioning GitLab uses [Semantic Versioning](https://semver.org/) for its releases: @@ -30,8 +35,6 @@ The following table describes the version types and their release cadence: ## Patch releases -Our current policy is to support **only the current stable release** at any given time. - Patch releases **only include bug fixes** for the current stable released version of GitLab. @@ -97,10 +100,7 @@ To request backporting to more than one stable release for consideration, raise ### Security releases Security releases are a special kind of patch release that only include security -fixes and patches (see below). - -Our current policy is to backport security fixes to the previous two -monthly releases in addition to the current stable release. +fixes and patches (see below) for the previous two monthly releases in addition to the current stable release. For very serious security issues, there is [precedent](https://about.gitlab.com/blog/2016/05/02/cve-2016-4340-patches/) @@ -139,6 +139,11 @@ We cannot guarantee that upgrading between major versions will be seamless. As p We recommend that you first upgrade to the latest available minor version within your major version. By doing this, you can address any deprecation messages that could change behavior in the next major release. + +It's also important to ensure that any background migrations have been fully completed +before upgrading to a new major version. To see the current size of the `background_migration` queue, +[Check for background migrations before upgrading](../update/README.md#checking-for-background-migrations-before-upgrading). + To ensure background migrations are successful, increment by one minor version during the version jump before installing newer releases. For example: `11.11.x` -> `12.0.x` @@ -151,9 +156,6 @@ Please see the table below for some examples: | 11.3.4 | 8.13.4 | `8.13.4` -> `8.17.7` -> `9.5.10` -> `10.8.7` -> `11.3.4` | `8.17.7` is the last version in version `8`, `9.5.10` is the last version in version `9`, `10.8.7` is the last version in version `10` | | 12.5.8 | 11.3.4 | `11.3.4` -> `11.11.8` -> `12.0.9` -> `12.5.8` | `11.11.8` is the last version in version `11` | -To check the size of `background_migration` queue and to learn more about background migrations -see [Upgrading without downtime](../update/README.md#upgrading-without-downtime). - More information about the release procedures can be found in our [release documentation](https://gitlab.com/gitlab-org/release/docs). You may also want to read our [Responsible Disclosure Policy](https://about.gitlab.com/security/disclosure/). diff --git a/doc/public_access/img/project_visibility_confirmation_v12_6.png b/doc/public_access/img/project_visibility_confirmation_v12_6.png index ac4d70ff11afaf1a7165c0f1af9fa77e23d24dba..8fba57f353b1c84425999696e96c2a792d2bacf2 100644 Binary files a/doc/public_access/img/project_visibility_confirmation_v12_6.png and b/doc/public_access/img/project_visibility_confirmation_v12_6.png differ diff --git a/doc/push_rules/push_rules.md b/doc/push_rules/push_rules.md index d778d6b929c76b2e80c8cfd6e35fc083cbc6ead6..f26cf0cece0034f4fdc461d4c9011a6e28b5364e 100644 --- a/doc/push_rules/push_rules.md +++ b/doc/push_rules/push_rules.md @@ -52,7 +52,7 @@ will get rejected. ### Custom Push Rules **(CORE ONLY)** It's possible to create custom push rules rather than the push rules available in -**Admin area > Push Rules** by using more advanced server-side Git hooks. +**Admin Area > Push Rules** by using more advanced server-side Git hooks. See [custom server-side Git hooks](../administration/custom_hooks.md) for more information. @@ -60,7 +60,7 @@ See [custom server-side Git hooks](../administration/custom_hooks.md) for more i NOTE: **Note:** GitLab administrators can set push rules globally under -**Admin area > Push Rules** that all new projects will inherit. You can later +**Admin Area > Push Rules** that all new projects will inherit. You can later override them in a project's settings. 1. Navigate to your project's **Settings > Repository** and expand **Push Rules** diff --git a/doc/raketasks/README.md b/doc/raketasks/README.md index ad86555fc1728dab1e0a7ef9c0f7bb0fb72c08d5..a9ba44f82c6d51680ab609af190833f232cd959e 100644 --- a/doc/raketasks/README.md +++ b/doc/raketasks/README.md @@ -14,5 +14,5 @@ comments: false - [Webhooks](web_hooks.md) - [Import](import.md) of Git repositories in bulk - [Rebuild authorized_keys file](../administration/raketasks/maintenance.md#rebuild-authorized_keys-file) task for administrators -- [Migrate Uploads](../administration/raketasks/uploads/migrate.md) -- [Sanitize Uploads](../administration/raketasks/uploads/sanitize.md) +- [Uploads Migrate](../administration/raketasks/uploads/migrate.md) +- [Uploads Sanitize](../administration/raketasks/uploads/sanitize.md) diff --git a/doc/raketasks/generate_sample_prometheus_data.md b/doc/raketasks/generate_sample_prometheus_data.md index 2489a2c2ad3a7770fc6529ed934e71b2cfb64498..bb0ed68ec0f18867f45bea65d2491afc180b5b06 100644 --- a/doc/raketasks/generate_sample_prometheus_data.md +++ b/doc/raketasks/generate_sample_prometheus_data.md @@ -1,11 +1,11 @@ # Generate Sample Prometheus Data This command will run Prometheus queries for each of the metrics of a specific environment -for a default time interval of 7 days ago to now. The results of each of query are stored -under a `sample_metrics` directory as a yaml file named by the metric's `identifier`. -When the environmental variable `USE_SAMPLE_METRICS` is set, the Prometheus API query is -re-routed to `Projects::Environments::SampleMetricsController` which loads the appropriate -data set if it is present within the `sample_metrics` directory. +for a series of time intervals: 30 minutes, 3 hours, 8 hours, 24 hours, 72 hours, and 7 days +to now. The results of each of query are stored under a `sample_metrics` directory as a yaml +file named by the metric's `identifier`. When the environmental variable `USE_SAMPLE_METRICS` +is set, the Prometheus API query is re-routed to `Projects::Environments::SampleMetricsController` +which loads the appropriate data set if it is present within the `sample_metrics` directory. - This command requires an id from an Environment with an available Prometheus installation. diff --git a/doc/security/README.md b/doc/security/README.md index fe96f7f284657dd6dd3e1d9348646cc3ee6a34c3..20da1a2c77c8749777fcf14955423ed65b7b5fdc 100644 --- a/doc/security/README.md +++ b/doc/security/README.md @@ -19,3 +19,9 @@ type: index - [Send email confirmation on sign-up](user_email_confirmation.md) - [Security of running jobs](https://docs.gitlab.com/runner/security/) - [Proxying images](asset_proxy.md) + +## Securing your GitLab installation + +To make sure your GitLab instance is safe and secure, please consider implementing +[Sign up restrictions](../user/admin_area/settings/sign_up_restrictions.md) to avoid +malicious users creating accounts. diff --git a/doc/security/asset_proxy.md b/doc/security/asset_proxy.md index 6e615028e8bb8772abc7365b2620f62373c454c6..5522a41ff01ca460519c9c00fa0adb4ccdee2feb 100644 --- a/doc/security/asset_proxy.md +++ b/doc/security/asset_proxy.md @@ -30,22 +30,22 @@ To install a Camo server as an asset proxy: 1. Make sure your instance of GitLab is running, and that you have created a private API token. Using the API, configure the asset proxy settings on your GitLab instance. For example: - ```sh - curl --request "PUT" "https://gitlab.example.com/api/v4/application/settings?\ - asset_proxy_enabled=true&\ - asset_proxy_url=https://proxy.gitlab.example.com&\ - asset_proxy_secret_key=<somekey>" \ - --header 'PRIVATE-TOKEN: <my_private_token>' - ``` - - The following settings are supported: - - | Attribute | Description | - |:-------------------------|:-------------------------------------------------------------------------------------------------------------------------------------| - | `asset_proxy_enabled` | Enable proxying of assets. If enabled, requires: `asset_proxy_url`). | - | `asset_proxy_secret_key` | Shared secret with the asset proxy server. | - | `asset_proxy_url` | URL of the asset proxy server. | - | `asset_proxy_whitelist` | Assets that match these domain(s) will NOT be proxied. Wildcards allowed. Your GitLab installation URL is automatically whitelisted. | + ```sh + curl --request "PUT" "https://gitlab.example.com/api/v4/application/settings?\ + asset_proxy_enabled=true&\ + asset_proxy_url=https://proxy.gitlab.example.com&\ + asset_proxy_secret_key=<somekey>" \ + --header 'PRIVATE-TOKEN: <my_private_token>' + ``` + + The following settings are supported: + + | Attribute | Description | + |:-------------------------|:-------------------------------------------------------------------------------------------------------------------------------------| + | `asset_proxy_enabled` | Enable proxying of assets. If enabled, requires: `asset_proxy_url`). | + | `asset_proxy_secret_key` | Shared secret with the asset proxy server. | + | `asset_proxy_url` | URL of the asset proxy server. | + | `asset_proxy_whitelist` | Assets that match these domain(s) will NOT be proxied. Wildcards allowed. Your GitLab installation URL is automatically whitelisted. | 1. Restart the server for the changes to take effect. Each time you change any values for the asset proxy, you need to restart the server. diff --git a/doc/security/password_length_limits.md b/doc/security/password_length_limits.md index 235730eb8256aec55ebca5f512e160b0e4857c42..c00fd78c4d323ee0e33c8d118e26ae62a9e026ad 100644 --- a/doc/security/password_length_limits.md +++ b/doc/security/password_length_limits.md @@ -49,7 +49,7 @@ From GitLab 12.6, the minimum password length set in this configuration file wil The user password length is set to a minimum of 8 characters by default. To change that using GitLab UI: -In the Admin area under **Settings** (`/admin/application_settings`), go to section **Sign-up Restrictions**. +In **Admin Area > Settings** (`/admin/application_settings`), go to the section **Sign-up restrictions**. [Minimum password length settings](../user/admin_area/img/minimum_password_length_settings_v12_6.png) diff --git a/doc/security/ssh_keys_restrictions.md b/doc/security/ssh_keys_restrictions.md index 4c60daf77f43176217320f50423c2e7b8aec4ef8..176b09168c46fdf96a739b01a1141133954fbbbe 100644 --- a/doc/security/ssh_keys_restrictions.md +++ b/doc/security/ssh_keys_restrictions.md @@ -17,8 +17,8 @@ algorithms. GitLab allows you to restrict the allowed SSH key technology as well as specify the minimum key length for each technology. -In the Admin area under **Settings** (`/admin/application_settings`), look for -the "Visibility and Access Controls" area: +In **Admin Area > Settings** (`/admin/application_settings`), expand the +**Visibility and access controls** section:  diff --git a/doc/security/two_factor_authentication.md b/doc/security/two_factor_authentication.md index defc4669e69c0365acc1a55f59ecd465c8265b0e..39f92f95ba1d66ff64285d2ec91f20f42518beac 100644 --- a/doc/security/two_factor_authentication.md +++ b/doc/security/two_factor_authentication.md @@ -25,7 +25,7 @@ won't be able to leave the 2FA configuration area at `/profile/two_factor_auth`. To enable 2FA for all users: -1. Navigate to **Admin area > Settings > General** (`/admin/application_settings`). +1. Navigate to **Admin Area > Settings > General** (`/admin/application_settings`). 1. Expand the **Sign-in restrictions** section, where you can configure both. If you want 2FA enforcement to take effect on next login, change the grace diff --git a/doc/security/user_email_confirmation.md b/doc/security/user_email_confirmation.md index 7ba50acbb0685cc0ffc8cfe4397730f5f5fb315d..3abfbe96a5963b4d44e961c1ee8321006486b888 100644 --- a/doc/security/user_email_confirmation.md +++ b/doc/security/user_email_confirmation.md @@ -8,7 +8,7 @@ GitLab can be configured to require confirmation of a user's email address when the user signs up. When this setting is enabled, the user is unable to sign in until they confirm their email address. -In the Admin area under **Settings** (`/admin/application_settings`), go to section +In **Admin Area > Settings** (`/admin/application_settings`), go to the section **Sign-up Restrictions** and look for the **Send confirmation email on sign-up** option. <!-- ## Troubleshooting diff --git a/doc/security/webhooks.md b/doc/security/webhooks.md index cb9ad2b694c91f60b9d216bc83af4ea48f0ba63e..010f5aa2d43a743a894a07848c8c21de7e9ab4ff 100644 --- a/doc/security/webhooks.md +++ b/doc/security/webhooks.md @@ -35,13 +35,12 @@ to endpoints like `http://localhost:123/some-resource/delete`. To prevent this type of exploitation from happening, starting with GitLab 10.6, all Webhook requests to the current GitLab instance server address and/or in a private network will be forbidden by default. That means that all requests made -to 127.0.0.1, ::1 and 0.0.0.0, as well as IPv4 10.0.0.0/8, 172.16.0.0/12, -192.168.0.0/16 and IPv6 site-local (ffc0::/10) addresses won't be allowed. +to `127.0.0.1`, `::1` and `0.0.0.0`, as well as IPv4 `10.0.0.0/8`, `172.16.0.0/12`, +`192.168.0.0/16` and IPv6 site-local (`ffc0::/10`) addresses won't be allowed. This behavior can be overridden by enabling the option *"Allow requests to the local network from web hooks and services"* in the *"Outbound requests"* section -inside the Admin area under **Settings** -(`/admin/application_settings/network`): +inside the **Admin Area > Settings** (`/admin/application_settings/network`):  @@ -61,7 +60,7 @@ and expand **Outbound requests**:  -The whilelist entries can be separated by semicolons, commas or whitespaces +The whitelist entries can be separated by semicolons, commas or whitespaces (including newlines) and be in different formats like hostnames, IP addresses and/or IP ranges. IPv6 is supported. Hostnames that contain unicode characters should use IDNA encoding. diff --git a/doc/ssh/README.md b/doc/ssh/README.md index 80b8db84bed629631f93c9f08f1f66a2ea1ab854..5a3f97de77dc4bdd870291fb7a086265b22b57ec 100644 --- a/doc/ssh/README.md +++ b/doc/ssh/README.md @@ -183,7 +183,7 @@ Now, it's time to add the newly created public key to your GitLab account. 1. Add your **public** SSH key to your GitLab account by: 1. Clicking your avatar in the upper right corner and selecting **Settings**. - 1. Navigating to **SSH Keys** and pasting your **public** key in the **Key** field. If you: + 1. Navigating to **SSH Keys** and pasting your **public** key from the clipboard into the **Key** field. If you: - Created the key with a comment, this will appear in the **Title** field. - Created the key without a comment, give your key an identifiable title like _Work Laptop_ or _Home Workstation_. 1. Click the **Add key** button. @@ -376,7 +376,7 @@ Global Shared Keys can provide greater security compared to Per-Project Deploy Keys since an administrator of the target integrated system is the only one who needs to know and configure the private key. -GitLab administrators set up Global Deploy keys in the Admin area under the +GitLab administrators set up Global Deploy keys in the Admin Area under the section **Deploy Keys**. Ensure keys have a meaningful title as that will be the primary way for project maintainers and owners to identify the correct Global Deploy key to add. For instance, if the key gives access to a SaaS CI instance, diff --git a/doc/tools/email.md b/doc/tools/email.md index 3088b0b63e73faedf296530c1e07321b17f7a836..e9ff88152ba183fbb495a0f5da149c46011b9f6e 100644 --- a/doc/tools/email.md +++ b/doc/tools/email.md @@ -5,7 +5,7 @@ type: howto, reference # Email from GitLab **(STARTER ONLY)** GitLab provides a simple tool to administrators for emailing all users, or users of -a chosen group or project, right from the admin area. Users will receive the email +a chosen group or project, right from the Admin Area. Users will receive the email at their primary email address. ## Use-cases @@ -16,8 +16,8 @@ at their primary email address. ## Sending emails to users from within GitLab -1. Go to the admin area using the wrench icon in the top right corner and - navigate to **Overview > Users > Send email to users**. +1. Navigate to the **Admin Area > Overview > Users** and press the + **Send email to users** button.  diff --git a/doc/topics/autodevops/img/autodevops_banner_v12_6.png b/doc/topics/autodevops/img/autodevops_banner_v12_6.png index 51ccdeeaa5200ce14e5a70095f9a01ef916f3a48..abf492c52473fdc815017c9af4391e4dcda8d352 100644 Binary files a/doc/topics/autodevops/img/autodevops_banner_v12_6.png and b/doc/topics/autodevops/img/autodevops_banner_v12_6.png differ diff --git a/doc/topics/autodevops/img/guide_base_domain_v12_3.png b/doc/topics/autodevops/img/guide_base_domain_v12_3.png index 0c8ab9b26e41a913a7c59efaae066a87b2bc8488..7d3b6a2f905a6c670256cba27c841a83ba6ec3fe 100644 Binary files a/doc/topics/autodevops/img/guide_base_domain_v12_3.png and b/doc/topics/autodevops/img/guide_base_domain_v12_3.png differ diff --git a/doc/topics/autodevops/img/guide_cluster_apps_v12_3.png b/doc/topics/autodevops/img/guide_cluster_apps_v12_3.png index f903ae40c0296afd8e273d13089f51f2261238b1..9be414434c7422ee7af58f62ce15b1ee7f03e138 100644 Binary files a/doc/topics/autodevops/img/guide_cluster_apps_v12_3.png and b/doc/topics/autodevops/img/guide_cluster_apps_v12_3.png differ diff --git a/doc/topics/autodevops/img/guide_create_project_v12_3.png b/doc/topics/autodevops/img/guide_create_project_v12_3.png index 68ab7f23f3c018ef6c5980ac164cfc4f1de89f18..a22730520ef9e75233713f51057f3fa3d2e0a482 100644 Binary files a/doc/topics/autodevops/img/guide_create_project_v12_3.png and b/doc/topics/autodevops/img/guide_create_project_v12_3.png differ diff --git a/doc/topics/autodevops/img/guide_enable_autodevops_v12_3.png b/doc/topics/autodevops/img/guide_enable_autodevops_v12_3.png index 7f0e7c60086199e202c8d4c2632b48c44773e46d..a3bcaeb99aebfa2461ac7a829398da1e8eb0798c 100644 Binary files a/doc/topics/autodevops/img/guide_enable_autodevops_v12_3.png and b/doc/topics/autodevops/img/guide_enable_autodevops_v12_3.png differ diff --git a/doc/topics/autodevops/img/guide_environments_metrics_v12_3.png b/doc/topics/autodevops/img/guide_environments_metrics_v12_3.png index 74f997a51227c43d7166a96932de8f2731025457..47bf3fda53a31ceec2f2f687e0a2204711cce475 100644 Binary files a/doc/topics/autodevops/img/guide_environments_metrics_v12_3.png and b/doc/topics/autodevops/img/guide_environments_metrics_v12_3.png differ diff --git a/doc/topics/autodevops/img/guide_environments_v12_3.png b/doc/topics/autodevops/img/guide_environments_v12_3.png index 0ad282cfe4e2edc1801c3e430e7d6ff992571423..87253f9929d69d8b38239c81ad3d932bcd43230a 100644 Binary files a/doc/topics/autodevops/img/guide_environments_v12_3.png and b/doc/topics/autodevops/img/guide_environments_v12_3.png differ diff --git a/doc/topics/autodevops/img/guide_first_pipeline_v12_3.png b/doc/topics/autodevops/img/guide_first_pipeline_v12_3.png index 7654b4f093423b47ce62f0f4e614ebee17fc4c6a..9b51b5cfdd81e4518e6b94f66ed547e12510ed76 100644 Binary files a/doc/topics/autodevops/img/guide_first_pipeline_v12_3.png and b/doc/topics/autodevops/img/guide_first_pipeline_v12_3.png differ diff --git a/doc/topics/autodevops/img/guide_gitlab_gke_details_v12_3.png b/doc/topics/autodevops/img/guide_gitlab_gke_details_v12_3.png index ba2b00dd984a7f9e660d49677320d477bbe4e6ee..2f3f8259316e85132ef6d4618ccc16d95b3f7b3c 100644 Binary files a/doc/topics/autodevops/img/guide_gitlab_gke_details_v12_3.png and b/doc/topics/autodevops/img/guide_gitlab_gke_details_v12_3.png differ diff --git a/doc/topics/autodevops/img/guide_google_signin_v12_3.png b/doc/topics/autodevops/img/guide_google_signin_v12_3.png index ac8a325dde62671385a8f6c7a110b952a052f4f1..58bcc5e67b65cc1c8afff384301a60811e250a37 100644 Binary files a/doc/topics/autodevops/img/guide_google_signin_v12_3.png and b/doc/topics/autodevops/img/guide_google_signin_v12_3.png differ diff --git a/doc/topics/autodevops/img/guide_ide_commit_v12_3.png b/doc/topics/autodevops/img/guide_ide_commit_v12_3.png index c40658e9ba98fb31a6d13a9bab29289760d3c81c..afea0dc8fb47f6e749bba9ff5e876c78c66d59f3 100644 Binary files a/doc/topics/autodevops/img/guide_ide_commit_v12_3.png and b/doc/topics/autodevops/img/guide_ide_commit_v12_3.png differ diff --git a/doc/topics/autodevops/img/guide_merge_request_review_app_v12_3.png b/doc/topics/autodevops/img/guide_merge_request_review_app_v12_3.png index e1a4f181744ac32334f2028a1eb771f0465934f4..e94654f4e50bfb02101d779ba8b50a09750a13fe 100644 Binary files a/doc/topics/autodevops/img/guide_merge_request_review_app_v12_3.png and b/doc/topics/autodevops/img/guide_merge_request_review_app_v12_3.png differ diff --git a/doc/topics/autodevops/img/guide_merge_request_v12_3.png b/doc/topics/autodevops/img/guide_merge_request_v12_3.png index 8c70620162cb7fa8d37ae69ab565fd473f97fcc9..5565be701cd02a6ef4c996f6c33ac9b15fbdbf78 100644 Binary files a/doc/topics/autodevops/img/guide_merge_request_v12_3.png and b/doc/topics/autodevops/img/guide_merge_request_v12_3.png differ diff --git a/doc/topics/autodevops/img/guide_pipeline_stages_v12_3.png b/doc/topics/autodevops/img/guide_pipeline_stages_v12_3.png index f55a985f5438fcf7fafd5a5bffc3574e665279f7..b9bab112a9fb65187526103f936c931b8654e6ad 100644 Binary files a/doc/topics/autodevops/img/guide_pipeline_stages_v12_3.png and b/doc/topics/autodevops/img/guide_pipeline_stages_v12_3.png differ diff --git a/doc/topics/autodevops/img/guide_project_landing_page_v12_3.png b/doc/topics/autodevops/img/guide_project_landing_page_v12_3.png index 4d62588ed908cd7251fe2ea173b4990827d0e00b..7eb2ee3692f608ac2bccb39ac85772ab66eb9da9 100644 Binary files a/doc/topics/autodevops/img/guide_project_landing_page_v12_3.png and b/doc/topics/autodevops/img/guide_project_landing_page_v12_3.png differ diff --git a/doc/topics/autodevops/img/guide_project_template_v12_3.png b/doc/topics/autodevops/img/guide_project_template_v12_3.png index 9ce730518d0eb12acb969a214a96e86c1ae3523d..2b8d7224747a72ad28299fb794c8e010924f4d49 100644 Binary files a/doc/topics/autodevops/img/guide_project_template_v12_3.png and b/doc/topics/autodevops/img/guide_project_template_v12_3.png differ diff --git a/doc/topics/autodevops/index.md b/doc/topics/autodevops/index.md index 33b13935de3840f3e55473a395659adb91a89fc1..c52c5832591ac89f443f43232e03a1d8bde19db4 100644 --- a/doc/topics/autodevops/index.md +++ b/doc/topics/autodevops/index.md @@ -19,12 +19,20 @@ For an introduction to Auto DevOps, watch [AutoDevOps in GitLab 11.0](https://yo ## Enabled by default -Starting with GitLab 11.3, the Auto DevOps pipeline is enabled by default for all -projects. If it has not been explicitly enabled for the project, Auto DevOps will be automatically -disabled on the first pipeline failure. Your project will continue to use an alternative -[CI/CD configuration file](../../ci/yaml/README.md) if one is found. A GitLab -administrator can [change this setting](../../user/admin_area/settings/continuous_integration.md#auto-devops-core-only) -in the admin area. +> [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/issues/41729) in GitLab 11.3. + +Auto DevOps is enabled by default for all projects and will attempt to run on all pipelines +in each project. This default can be enabled or disabled by an instance administrator in the +[Auto DevOps settings](../../user/admin_area/settings/continuous_integration.md#auto-devops-core-only). +It will be automatically disabled in individual projects on their first pipeline failure, +if it has not been explicitly enabled for the project. + +Since [GitLab 12.7](https://gitlab.com/gitlab-org/gitlab/issues/26655), Auto DevOps +will run on pipelines automatically only if a [`Dockerfile` or matching buildpack](#auto-build) +exists. + +If a [CI/CD configuration file](../../ci/yaml/README.md) is present in the project, +it will continue to be used, whether or not Auto DevOps is enabled. ## Quick start @@ -176,7 +184,7 @@ The Auto DevOps base domain is required if you want to make use of places: - either under the cluster's settings, whether for [projects](../../user/project/clusters/index.md#base-domain) or [groups](../../user/group/clusters/index.md#base-domain) -- or in instance-wide settings in the **admin area > Settings** under the "Continuous Integration and Delivery" section +- or in instance-wide settings in the **Admin Area > Settings** under the "Continuous Integration and Delivery" section - or at the project level as a variable: `KUBE_INGRESS_BASE_DOMAIN` - or at the group level as a variable: `KUBE_INGRESS_BASE_DOMAIN`. @@ -255,7 +263,7 @@ the subgroup or project. Even when disabled at the instance level, group owners and project maintainers can still enable Auto DevOps at the group and project level, respectively. -1. Go to **Admin area > Settings > Continuous Integration and Deployment**. +1. Go to **Admin Area > Settings > Continuous Integration and Deployment**. 1. Toggle the checkbox labeled **Default to Auto DevOps pipeline for all projects**. 1. If enabling, optionally set up the Auto DevOps [base domain](#auto-devops-base-domain) which will be used for Auto Deploy and Auto Review Apps. 1. Click **Save changes** for the changes to take effect. diff --git a/doc/topics/autodevops/quick_start_guide.md b/doc/topics/autodevops/quick_start_guide.md index ce3a3dd5ca6d9764b09c4f54b72fc5b9a5ae878d..32dcd60624f267122cdd6884f28ff335c777c9a9 100644 --- a/doc/topics/autodevops/quick_start_guide.md +++ b/doc/topics/autodevops/quick_start_guide.md @@ -30,7 +30,7 @@ Google Kubernetes Engine Integration. All you have to do is [follow this link](h ## Creating a new project from a template We will use one of GitLab's project templates to get started. As the name suggests, -those projects provide a barebones application built on some well-known frameworks. +those projects provide a bare-bones application built on some well-known frameworks. 1. In GitLab, click the plus icon (**+**) at the top of the navigation bar and select **New project**. diff --git a/doc/topics/git/useful_git_commands.md b/doc/topics/git/useful_git_commands.md index abd06b95b1e13aa26eaaec0544d5a7cc2b4a7dc9..68b4c772b8b05c52689d662730c5253c0f400b56 100644 --- a/doc/topics/git/useful_git_commands.md +++ b/doc/topics/git/useful_git_commands.md @@ -74,7 +74,7 @@ message. git stash save ``` -The default behavor of `stash` is to save, so you can also use just: +The default behavior of `stash` is to save, so you can also use just: ```sh git stash diff --git a/doc/update/README.md b/doc/update/README.md index 6834deb1a85a7723a0872c016656a775fcfe4351..f23716f3df889e181a26e44565e3c98d0d001ffa 100644 --- a/doc/update/README.md +++ b/doc/update/README.md @@ -69,13 +69,8 @@ before continuing the upgrading procedure. While this won't require downtime between upgrading major/minor releases, allowing the background migrations to finish. The time necessary to complete these migrations can be reduced by increasing the number of Sidekiq workers that can process jobs in the -`background_migration` queue. To check the size of this queue, -[start a Rails console session](https://docs.gitlab.com/omnibus/maintenance/#starting-a-rails-console-session) -and run the command below: - -```ruby -Sidekiq::Queue.new('background_migration').size -``` +`background_migration` queue. To see the size of this queue, +[Check for background migrations before upgrading](#checking-for-background-migrations-before-upgrading). As a rule of thumb, any database smaller than 10 GB won't take too much time to upgrade; perhaps an hour at most per minor release. Larger databases however may @@ -112,6 +107,36 @@ meet the other online upgrade requirements mentioned above. Steps to [upgrade without downtime][omni-zero-downtime]. +## Checking for background migrations before upgrading + +Certain major/minor releases may require a set of background migrations to be +finished. The number of remaining migrations jobs can be found by running the +following command: + +**For Omnibus installations** + +```bash +sudo gitlab-rails runner -e production 'puts Sidekiq::Queue.new("background_migration").size' +``` + +**For installations from source** + +``` +cd /home/git/gitlab +sudo -u git -H bundle exec rails runner -e production 'puts Sidekiq::Queue.new("background_migration").size' +``` + +## Upgrading to a new major version + +Major versions are reserved for backwards incompatible changes. We recommend that +you first upgrade to the latest available minor version within your major version. +Please follow the [Upgrade Recommendations](../policy/maintenance.md#upgrade-recommendations) +to identify the ideal upgrade path. + +Before upgrading to a new major version, you should ensure that any background +migration jobs from previous releases have been completed. To see the current size +of the `background_migration` queue, [check for background migrations before upgrading](#checking-for-background-migrations-before-upgrading). + ## Upgrading between editions GitLab comes in two flavors: [Community Edition][ce] which is MIT licensed, diff --git a/doc/update/patch_versions.md b/doc/update/patch_versions.md index b00fc5d90cff6766b6c42ecd3563f23c01a62f84..5aa97d82fd1da3f48b8e03f0d634855a130353af 100644 --- a/doc/update/patch_versions.md +++ b/doc/update/patch_versions.md @@ -94,11 +94,9 @@ sudo -u git -H git checkout v$(</home/git/gitlab/GITLAB_PAGES_VERSION) sudo -u git -H make ``` -### 8. Install/Update `gitlab-elasticsearch-indexer` (optional) **(STARTER ONLY)** +### 8. Install/Update `gitlab-elasticsearch-indexer` **(STARTER ONLY)** -If you're interested in using GitLab's new [Elasticsearch repository indexer](../integration/elasticsearch.md#elasticsearch-repository-indexer-beta) (currently in beta) -please follow the instructions on the document linked above and enable the -indexer usage in the GitLab admin settings. +Please follow the [install instruction](../integration/elasticsearch.md#installation). ### 9. Start application diff --git a/doc/update/upgrading_from_ce_to_ee.md b/doc/update/upgrading_from_ce_to_ee.md index 52a65a89cbf17286c84aa9a4acb02afd1afb2f13..d1853466e30be1c7c1524defa270286dedcd6988 100644 --- a/doc/update/upgrading_from_ce_to_ee.md +++ b/doc/update/upgrading_from_ce_to_ee.md @@ -77,11 +77,9 @@ sudo -u git -H bundle exec rake yarn:install gitlab:assets:clean gitlab:assets:c sudo -u git -H bundle exec rake cache:clear RAILS_ENV=production ``` -### 4. Install `gitlab-elasticsearch-indexer` (optional) **(STARTER ONLY)** +### 4. Install `gitlab-elasticsearch-indexer` **(STARTER ONLY)** -If you're interested in using GitLab's new [Elasticsearch repository indexer](../integration/elasticsearch.md) -(currently in beta) please follow the instructions on the -document linked above and enable the indexer usage in the GitLab admin settings. +Please follow the [install instruction](../integration/elasticsearch.md#installation). ### 5. Start application diff --git a/doc/update/upgrading_from_source.md b/doc/update/upgrading_from_source.md index 662701dbb5623ef7df41453931601c14c911ff56..48f8052cbb855d2c7ca4fce484b62795095f008c 100644 --- a/doc/update/upgrading_from_source.md +++ b/doc/update/upgrading_from_source.md @@ -23,6 +23,17 @@ guide links by version. If you are changing from GitLab Community Edition to GitLab Enterprise Edition, see the [Upgrading from CE to EE](upgrading_from_ce_to_ee.md) documentation. +## Upgrading to a new major version + +Major versions are reserved for backwards incompatible changes. We recommend that +you first upgrade to the latest available minor version within your major version. +Please follow the [Upgrade Recommendations](../policy/maintenance.md#upgrade-recommendations) +to identify the ideal upgrade path. + +Before upgrading to a new major version, you should ensure that any background +migration jobs from previous releases have been completed. To see the current size of the `background_migration` queue, +[Check for background migrations before upgrading](README.md#checking-for-background-migrations-before-upgrading). + ## Guidelines for all versions This section contains all the steps necessary to upgrade Community Edition or @@ -56,9 +67,9 @@ Download Ruby and compile it: ```bash mkdir /tmp/ruby && cd /tmp/ruby -curl --remote-name --progress https://cache.ruby-lang.org/pub/ruby/2.6/ruby-2.6.3.tar.gz -echo '2347ed6ca5490a104ebd5684d2b9b5eefa6cd33c ruby-2.6.3.tar.gz' | shasum -c - && tar xzf ruby-2.6.3.tar.gz -cd ruby-2.6.3 +curl --remote-name --progress https://cache.ruby-lang.org/pub/ruby/2.6/ruby-2.6.5.tar.gz +echo '1416ce288fb8bfeae07a12b608540318c9cace71 ruby-2.6.5.tar.gz' | shasum -c - && tar xzf ruby-2.6.5.tar.gz +cd ruby-2.6.5 ./configure --disable-install-rdoc make @@ -71,24 +82,15 @@ Install Bundler: sudo gem install bundler --no-document --version '< 2' ``` -### 4. Update Node - -NOTE: Beginning in GitLab 11.8, we only support node 8 or higher, and dropped -support for node 6. Be sure to upgrade if necessary. - -GitLab utilizes [webpack](https://webpack.js.org/) to compile frontend assets. -This requires a minimum version of node v8.10.0. - -You can check which version you are running with `node -v`. If you are running -a version older than `v8.10.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. +### 4. Update Node.js -<https://nodejs.org/en/download/> +NOTE: To check the minimum required Node.js version, see [Node.js versions](../install/requirements.md#nodejs-versions). -GitLab also requires the use of yarn `>= v1.10.0` to manage JavaScript +GitLab also requires the use of Yarn `>= v1.10.0` to manage JavaScript dependencies. +In Debian or Ubuntu: + ```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 @@ -96,34 +98,33 @@ sudo apt-get update sudo apt-get install yarn ``` -More information can be found on the [yarn website](https://yarnpkg.com/en/docs/install). +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. +NOTE: To check the minimum required Go version, see [Go versions](../install/requirements.md#go-versions). You can check which version you are running with `go version`. -Download and install Go: +Download and install Go (for Linux, 64-bit): ```bash # Remove former Go installation folder sudo rm -rf /usr/local/go -curl --remote-name --progress https://dl.google.com/go/go1.11.10.linux-amd64.tar.gz -echo 'aefaa228b68641e266d1f23f1d95dba33f17552ba132878b65bb798ffa37e6d0 go1.11.10.linux-amd64.tar.gz' | shasum -a256 -c - && \ - sudo tar -C /usr/local -xzf go1.11.10.linux-amd64.tar.gz +curl --remote-name --progress https://dl.google.com/go/go1.13.5.linux-amd64.tar.gz +echo '512103d7ad296467814a6e3f635631bd35574cab3369a97a323c9a585ccaa569 go1.13.5.linux-amd64.tar.gz' | shasum -a256 -c - && \ + sudo tar -C /usr/local -xzf go1.13.5.linux-amd64.tar.gz sudo ln -sf /usr/local/go/bin/{go,godoc,gofmt} /usr/local/bin/ -rm go1.11.10.linux-amd64.tar.gz +rm go1.13.5.linux-amd64.tar.gz + ``` ### 6. Update Git -NOTE: **Note:** -GitLab 11.11 and higher only supports Git 2.21.x and newer, and -[dropped support for older versions](https://gitlab.com/gitlab-org/gitlab-foss/issues/54255). -Be sure to upgrade your installation if necessary. +NOTE: To check the minimum required Git version, see [Git versions](../install/requirements.md#git-versions). + +In Debian or Ubuntu: ```bash # Make sure Git is version 2.21.0 or higher @@ -243,9 +244,8 @@ sudo -u git -H make #### 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`: +There might be configuration options available for [`gitlab.yml`](https://gitlab.com/gitlab-org/gitlab/blob/master/config/gitlab.yml.example)). +View them with the command below and apply them manually to your current `gitlab.yml`: ```sh cd /home/git/gitlab @@ -271,7 +271,7 @@ 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]. +If you are using Apache instead of NGINX see the updated [Apache templates](https://gitlab.com/gitlab-org/gitlab-recipes/tree/master/web-server/apache). 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`](https://gitlab.com/gitlab-org/gitlab/blob/master/lib/support/init.d/gitlab.default.example#L38). @@ -285,13 +285,13 @@ add the following line to `config/initializers/smtp_settings.rb`: ActionMailer::Base.delivery_method = :smtp ``` -See [smtp_settings.rb.sample] as an example. +See [smtp_settings.rb.sample](https://gitlab.com/gitlab-org/gitlab/blob/master/config/initializers/smtp_settings.rb.sample#L13) as an example. #### 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`: +[`gitlab.default.example`](https://gitlab.com/gitlab-org/gitlab/blob/master/lib/support/init.d/gitlab.default.example). +View them with the command below and apply them manually to your current `/etc/default/gitlab`: ```sh cd /home/git/gitlab @@ -378,17 +378,19 @@ Example: Additional instructions here. --> -## Things went south? Revert to previous version +## Troubleshooting ### 1. Revert the code to the previous version -To revert to a previous version, you'll need to following the upgrading guides -for the previous version. If you upgraded to 11.8 and want to revert back to -11.7, you'll need to follow the guides for upgrading from 11.6 to 11.7. You can +To revert to a previous version, you need to follow the upgrading guides +for the previous version. + +For example, if you have upgraded to GitLab 12.6 and want to revert back to +12.5, you need to follow the guides for upgrading from 12.4 to 12.5. You can use the version dropdown at the top of the page to select the right version. -When reverting, you should _not_ follow the database migration guides, as the -backup is already migrated to the previous version. +When reverting, you should **not** follow the database migration guides, as the +backup has already been migrated to the previous version. ### 2. Restore from the backup @@ -398,9 +400,4 @@ 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/blob/master/config/gitlab.yml.example -[gl-example]: https://gitlab.com/gitlab-org/gitlab/blob/master/lib/support/init.d/gitlab.default.example -[smtp_settings.rb.sample]: https://gitlab.com/gitlab-org/gitlab/blob/master/config/initializers/smtp_settings.rb.sample#L13 -[Apache templates]: https://gitlab.com/gitlab-org/gitlab-recipes/tree/master/web-server/apache +If you have more than one backup `*.tar` file, add `BACKUP=timestamp_of_backup` to the above. diff --git a/doc/user/admin_area/custom_project_templates.md b/doc/user/admin_area/custom_project_templates.md index 8a0cc95ade0fe7301752166861143e6bdd8e1973..f9a4dad250018f93b4ecdfbd15bb21d3d3880611 100644 --- a/doc/user/admin_area/custom_project_templates.md +++ b/doc/user/admin_area/custom_project_templates.md @@ -28,7 +28,7 @@ see [Custom group-level project templates](../group/custom_project_templates.md) GitLab administrators can configure a GitLab group that serves as template source for an entire GitLab instance by: -1. Navigating to **Admin area > Settings > Templates**. +1. Navigating to **Admin Area > Settings > Templates**. 1. Expanding **Custom project templates**. 1. Selecting a group to use. 1. Pressing **Save changes**. diff --git a/doc/user/admin_area/diff_limits.md b/doc/user/admin_area/diff_limits.md index 4e24c25de8ff0b129487949d1e6b346c213b4bd1..bc6f93891dfd13eeb9d74a95a47a98bb1c595db9 100644 --- a/doc/user/admin_area/diff_limits.md +++ b/doc/user/admin_area/diff_limits.md @@ -24,7 +24,7 @@ CAUTION: **Caution:** This setting is experimental. An increased maximum will increase resource consumption of your instance. Keep this in mind when adjusting the maximum. -1. Go to **Admin area > Settings > General**. +1. Go to **Admin Area > Settings > General**. 1. Expand **Diff limits**. 1. Enter a value for **Maximum diff patch size**, measured in bytes. 1. Click on **Save changes**. diff --git a/doc/user/admin_area/geo_nodes.md b/doc/user/admin_area/geo_nodes.md index bbdb9cb07a636b9657df8fe45e805fcc487fd24f..250956beb63dd168c3bde84723a0d45b44df9767 100644 --- a/doc/user/admin_area/geo_nodes.md +++ b/doc/user/admin_area/geo_nodes.md @@ -2,12 +2,12 @@ type: howto --- -# Geo nodes admin area **(PREMIUM ONLY)** +# Geo nodes Admin Area **(PREMIUM ONLY)** You can configure various settings for GitLab Geo nodes. For more information, see [Geo documentation](../../administration/geo/replication/index.md). -On the primary node, go to **Admin area > Geo**. On secondary nodes, go to **Admin area > Geo > Nodes**. +On the primary node, go to **Admin Area > Geo**. On secondary nodes, go to **Admin Area > Geo > Nodes**. ## Common settings @@ -59,7 +59,7 @@ The **primary** node's Internal URL is used by **secondary** nodes to contact it which is used by users. Internal URL does not need to be a private address. Internal URL defaults to External URL, but you can customize it under -**Admin area > Geo Nodes**. +**Admin Area > Geo > Nodes**. CAUTION: **Warning:** We recommend using an HTTPS connection while configuring the Geo nodes. To avoid diff --git a/doc/user/admin_area/img/appearance_favicon_v12_3.png b/doc/user/admin_area/img/appearance_favicon_v12_3.png index b464c9087e9513db39683a8d5bc241e865ea8e1f..0bab0638265dfff3269d504d6daac6eb3fad75f5 100644 Binary files a/doc/user/admin_area/img/appearance_favicon_v12_3.png and b/doc/user/admin_area/img/appearance_favicon_v12_3.png differ diff --git a/doc/user/admin_area/img/appearance_header_footer_v12_3.png b/doc/user/admin_area/img/appearance_header_footer_v12_3.png index aed0ff820fbbbd556943ece72a7ccdfa4aeac264..da68ddcf16652dd82bc5e0021e8d11d0d28d3bf9 100644 Binary files a/doc/user/admin_area/img/appearance_header_footer_v12_3.png and b/doc/user/admin_area/img/appearance_header_footer_v12_3.png differ diff --git a/doc/user/admin_area/img/appearance_header_logo_v12_3.png b/doc/user/admin_area/img/appearance_header_logo_v12_3.png index 0da56d196c05bca548fff6b7268e9c6223c2f21b..414301b8efa615f67a914acdf1f9bb475d3cbeed 100644 Binary files a/doc/user/admin_area/img/appearance_header_logo_v12_3.png and b/doc/user/admin_area/img/appearance_header_logo_v12_3.png differ diff --git a/doc/user/admin_area/img/appearance_new_project_preview_v12_3.png b/doc/user/admin_area/img/appearance_new_project_preview_v12_3.png index 621e62e787b2ec321e1d7af391d3458da5ad669b..92593399843fe2b5f83357c38f6029a3a24906b0 100644 Binary files a/doc/user/admin_area/img/appearance_new_project_preview_v12_3.png and b/doc/user/admin_area/img/appearance_new_project_preview_v12_3.png differ diff --git a/doc/user/admin_area/img/appearance_new_project_v12_3.png b/doc/user/admin_area/img/appearance_new_project_v12_3.png index ae1a8ca0f8522cb0d0a5ca63d8d95298be39a4a4..120a191de27a0bb2e978265763000c4052546683 100644 Binary files a/doc/user/admin_area/img/appearance_new_project_v12_3.png and b/doc/user/admin_area/img/appearance_new_project_v12_3.png differ diff --git a/doc/user/admin_area/img/appearance_sign_in_preview_v12_3.png b/doc/user/admin_area/img/appearance_sign_in_preview_v12_3.png index 64bd62c2d326335ab97e12e2060b6096b7546756..59c190393d1c8289c8bef35e4a481743792c687f 100644 Binary files a/doc/user/admin_area/img/appearance_sign_in_preview_v12_3.png and b/doc/user/admin_area/img/appearance_sign_in_preview_v12_3.png differ diff --git a/doc/user/admin_area/img/appearance_sign_in_v12_3.png b/doc/user/admin_area/img/appearance_sign_in_v12_3.png index 6abe10f8bea30a7651d07d698197b626a2b8fc63..7e2337bbae84ea5e936f8fef5893186b311bff3c 100644 Binary files a/doc/user/admin_area/img/appearance_sign_in_v12_3.png and b/doc/user/admin_area/img/appearance_sign_in_v12_3.png differ diff --git a/doc/user/admin_area/img/credentials_inventory_v12_6.png b/doc/user/admin_area/img/credentials_inventory_v12_6.png index ff46db61cdb6ffa159c44ac3a80aef1413b13d7c..5c16781cb2d4e847d46d8f3d46b1d8cc78cb185f 100644 Binary files a/doc/user/admin_area/img/credentials_inventory_v12_6.png and b/doc/user/admin_area/img/credentials_inventory_v12_6.png differ diff --git a/doc/user/admin_area/img/minimum_password_length_settings_v12_6.png b/doc/user/admin_area/img/minimum_password_length_settings_v12_6.png index f75d9e9bb298c565286850da364a91dbee14abbb..27634a02a8a820b4ca85f39a18895117ca8c83ec 100644 Binary files a/doc/user/admin_area/img/minimum_password_length_settings_v12_6.png and b/doc/user/admin_area/img/minimum_password_length_settings_v12_6.png differ diff --git a/doc/user/admin_area/index.md b/doc/user/admin_area/index.md index 35cb2b42c56400612ecaa502742ff4ca552983b2..7d710e3b2c14f0c2d5276b75aac6d914190a84f6 100644 --- a/doc/user/admin_area/index.md +++ b/doc/user/admin_area/index.md @@ -18,22 +18,24 @@ Only admin users can access the Admin Area. The Admin Area is made up of the following sections: -| Section | Description | -|:------------------------------|:-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| [Overview](#overview-section) | View your GitLab [Dashboard](#admin-dashboard), and administer [projects](#administering-projects), [users](#administering-users), [groups](#administering-groups), [jobs](#administering-jobs), [Runners](#administering-runners), and [Gitaly servers](#administering-gitaly-servers). | -| Monitoring | View GitLab [system information](#system-info), and information on [background jobs](#background-jobs), [logs](#logs), [health checks](monitoring/health_check.md), [requests profiles](#requests-profiles), and [audit logs](#audit-log-premium-only). | -| Messages | Send and manage [broadcast messages](broadcast_messages.md) for your users. | -| System Hooks | Configure [system hooks](../../system_hooks/system_hooks.md) for many events. | -| Applications | Create system [OAuth applications](../../integration/oauth_provider.md) for integrations with other services. | -| Abuse Reports | Manage [abuse reports](abuse_reports.md) submitted by your users. | -| License **(STARTER ONLY)** | Upload, display, and remove [licenses](license.md). | -| Push Rules **(STARTER)** | Configure pre-defined Git [push rules](../../push_rules/push_rules.md) for projects. | -| Geo **(PREMIUM ONLY)** | Configure and maintain [Geo nodes](geo_nodes.md). | -| Deploy Keys | Create instance-wide [SSH deploy keys](../../ssh/README.md#deploy-keys). | -| Service Templates | Create [service templates](../project/integrations/services_templates.md) for projects. | -| Labels | Create and maintain [labels](labels.md) for your GitLab instance. | -| Appearance | Customize [GitLab's appearance](appearance.md). | -| Settings | Modify the [settings](settings/index.md) for your GitLab instance. | +| Section | Description | +|:--------------------------------|:-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| [Overview](#overview-section) | View your GitLab [Dashboard](#admin-dashboard), and administer [projects](#administering-projects), [users](#administering-users), [groups](#administering-groups), [jobs](#administering-jobs), [Runners](#administering-runners), and [Gitaly servers](#administering-gitaly-servers). | +| Monitoring | View GitLab [system information](#system-info), and information on [background jobs](#background-jobs), [logs](#logs), [health checks](monitoring/health_check.md), [requests profiles](#requests-profiles), and [audit logs](#audit-log-premium-only). | +| Messages | Send and manage [broadcast messages](broadcast_messages.md) for your users. | +| System Hooks | Configure [system hooks](../../system_hooks/system_hooks.md) for many events. | +| Applications | Create system [OAuth applications](../../integration/oauth_provider.md) for integrations with other services. | +| Abuse Reports | Manage [abuse reports](abuse_reports.md) submitted by your users. | +| License **(STARTER ONLY)** | Upload, display, and remove [licenses](license.md). | +| Kubernetes | Create and manage instance-level [Kubernetes clusters](../instance/clusters/index.md). | +| Push Rules **(STARTER)** | Configure pre-defined Git [push rules](../../push_rules/push_rules.md) for projects. | +| Geo **(PREMIUM ONLY)** | Configure and maintain [Geo nodes](geo_nodes.md). | +| Deploy Keys | Create instance-wide [SSH deploy keys](../../ssh/README.md#deploy-keys). | +| Credentials **(ULTIMATE ONLY)** | View [credentials](credentials_inventory.md) that can be used to access your instance. | +| Service Templates | Create [service templates](../project/integrations/services_templates.md) for projects. | +| Labels | Create and maintain [labels](labels.md) for your GitLab instance. | +| Appearance | Customize [GitLab's appearance](appearance.md). | +| Settings | Modify the [settings](settings/index.md) for your GitLab instance. | ## Admin Dashboard diff --git a/doc/user/admin_area/license.md b/doc/user/admin_area/license.md index fe8903a9f01561ae82eb6960ef56196ec87dae04..c9d8a457a5178d4ada9e90fdac6d08ce71583382 100644 --- a/doc/user/admin_area/license.md +++ b/doc/user/admin_area/license.md @@ -24,17 +24,17 @@ will be locked. The very first time you visit your GitLab EE installation signed in as an admin, you should see a note urging you to upload a license with a link that takes you -straight to the License admin area. +straight to **Admin Area > License**. Otherwise, you can: 1. Navigate manually to the **Admin Area** by clicking the wrench icon in the menu bar. -  +  1. And then going to the **License** tab and click on **Upload New License**. -  +  1. If you've received a `.gitlab-license` file, you should have already downloaded it in your local machine. You can then upload it directly by choosing the @@ -69,7 +69,7 @@ gitlab_rails['license_file'] = "/path/to/license/file" CAUTION: **Caution:** These methods will only add a license at the time of installation. Use the -admin area in the web ui to renew or upgrade licenses. +Admin Area in the web ui to renew or upgrade licenses. --- diff --git a/doc/user/admin_area/monitoring/health_check.md b/doc/user/admin_area/monitoring/health_check.md index 68767efc72aaeead6c512cd0d2c4edcf4d325f94..fe38c350f190753c2bebe4094e27bcf7bf5c00d8 100644 --- a/doc/user/admin_area/monitoring/health_check.md +++ b/doc/user/admin_area/monitoring/health_check.md @@ -141,7 +141,7 @@ This check is being exempt from Rack Attack. > Access token has been deprecated in GitLab 9.4 in favor of [IP whitelist](#ip-whitelist). An access token needs to be provided while accessing the probe endpoints. The current -accepted token can be found under the **Admin area ➔ Monitoring ➔ Health check** +accepted token can be found under the **Admin Area > Monitoring > Health check** (`admin/health_check`) page of your GitLab instance.  diff --git a/doc/user/admin_area/settings/account_and_limit_settings.md b/doc/user/admin_area/settings/account_and_limit_settings.md index 9d82b3b4292030da4175c2904e29db5257ef4bb0..131ff949cd1d042ff20a6d3bc65e974e74170301 100644 --- a/doc/user/admin_area/settings/account_and_limit_settings.md +++ b/doc/user/admin_area/settings/account_and_limit_settings.md @@ -106,7 +106,7 @@ To set a limit on how long personal access tokens are valid: 1. Navigate to **Admin Area > Settings > General**. 1. Expand the **Account and limit** section. -1. Fill in the **Maximun allowable lifetime for personal access tokens (days)** field. +1. Fill in the **Maximum allowable lifetime for personal access tokens (days)** field. 1. Click **Save changes**. Once a lifetime for personal access tokens is set, GitLab will: @@ -116,3 +116,17 @@ Once a lifetime for personal access tokens is set, GitLab will: - After three hours, revoke old tokens with no expiration date or with a lifetime longer than the allowed lifetime. Three hours is given to allow administrators to change the allowed lifetime, or remove it, before revocation takes place. + +## Disabling user profile name changes **(PREMIUM ONLY)** + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/24605) in GitLab 12.7. + +To maintain integrity of user details in [Audit Events](../../../administration/audit_events.md), GitLab administrators can choose to disable a user's ability to change their profile name. + +To do this: + +1. Navigate to **Admin Area > Settings > General**, then expand **Account and Limit**. +1. Check the **Prevent users from changing their profile name** checkbox. + +NOTE: **Note:** +When this ability is disabled, GitLab administrators will still be able to update the name of any user in their instance via the [Admin UI](../index.md#administering-users) or the [API](../../../api/users.md#user-modification) diff --git a/doc/user/admin_area/settings/continuous_integration.md b/doc/user/admin_area/settings/continuous_integration.md index 79deda73d344cf04f0ccb4d6642594706e0a881e..43b0671d9e027e7626ce8878360413f3da4cfd79 100644 --- a/doc/user/admin_area/settings/continuous_integration.md +++ b/doc/user/admin_area/settings/continuous_integration.md @@ -5,16 +5,16 @@ type: reference # Continuous Integration and Deployment Admin settings **(CORE ONLY)** In this area, you will find settings for Auto DevOps, Runners and job artifacts. -You can find it in the admin area, under **Settings > Continuous Integration and Deployment**. +You can find it in the **Admin Area > Settings > CI/CD**. - + ## Auto DevOps **(CORE ONLY)** To enable (or disable) [Auto DevOps](../../../topics/autodevops/index.md) for all projects: -1. Go to **Admin area > Settings > Continuous Integration and Deployment** +1. Go to **Admin Area > Settings > CI/CD** 1. Check (or uncheck to disable) the box that says "Default to Auto DevOps pipeline for all projects" 1. Optionally, set up the [Auto DevOps base domain](../../../topics/autodevops/index.md#auto-devops-base-domain) which is going to be used for Auto Deploy and Auto Review Apps. @@ -43,7 +43,7 @@ To change it at the: - Instance level: - 1. Go to **Admin area > Settings > Continuous Integration and Deployment**. + 1. Go to **Admin Area > Settings > CI/CD**. 1. Change the value of maximum artifacts size (in MB). 1. Hit **Save changes** for the changes to take effect. @@ -65,12 +65,12 @@ The setting at all levels is only available to GitLab administrators. ## Default artifacts expiration **(CORE ONLY)** The default expiration time of the [job artifacts](../../../administration/job_artifacts.md) -can be set in the Admin area of your GitLab instance. The syntax of duration is +can be set in the Admin Area of your GitLab instance. The syntax of duration is described in [`artifacts:expire_in`](../../../ci/yaml/README.md#artifactsexpire_in) and the default value is `30 days`. On GitLab.com they [never expire](../../gitlab_com/index.md#gitlab-cicd). -1. Go to **Admin area > Settings > Continuous Integration and Deployment**. +1. Go to **Admin Area > Settings > CI/CD**. 1. Change the value of default expiration time. 1. Hit **Save changes** for the changes to take effect. @@ -78,6 +78,12 @@ This setting is set per job and can be overridden in [`.gitlab-ci.yml`](../../../ci/yaml/README.md#artifactsexpire_in). To disable the expiration, set it to `0`. The default unit is in seconds. +NOTE: **Note** +Any changes to this setting will apply to new artifacts only. The expiration time will not +be updated for artifacts created before this setting was changed. +The administrator may need to manually search for and expire previously-created +artifacts, as described in the [troubleshooting documentation](../../../administration/troubleshooting/gitlab_rails_cheat_sheet.md#remove-artifacts-more-than-a-week-old). + ## Shared Runners pipeline minutes quota **(STARTER ONLY)** > [Introduced](https://gitlab.com/gitlab-org/gitlab/merge_requests/1078) @@ -93,17 +99,17 @@ On GitLab.com, the quota is calculated based on your To change the pipelines minutes quota: -1. Go to **Admin area > Settings > Continuous Integration and Deployment** +1. Go to **Admin Area > Settings > CI/CD** 1. Set the pipeline minutes quota limit. 1. Hit **Save changes** for the changes to take effect --- -While the setting in the Admin area has a global effect, as an admin you can +While the setting in the Admin Area has a global effect, as an admin you can also change each group's pipeline minutes quota to override the global value. -1. Navigate to the **Groups** admin area and hit the **Edit** button for the - group you wish to change the pipeline minutes quota. +1. Navigate to the **Admin Area > Overview > Groups** and hit the **Edit** + button for the group you wish to change the pipeline minutes quota. 1. Set the pipeline minutes quota to the desired value 1. Hit **Save changes** for the changes to take effect. @@ -126,8 +132,9 @@ but persisting the traces and artifacts for auditing purposes. To set the duration for which the jobs will be considered as old and expired: -1. Go to **Admin area > Settings > CI/CD > Continuous Integration and Deployment**. -1. Change the value of "Archive jobs". +1. Go to **Admin Area > Settings > CI/CD**. +1. Expand the **Continuous Integration and Deployment** section. +1. Set the value of **Archive jobs**. 1. Hit **Save changes** for the changes to take effect. Once that time passes, the jobs will be archived and no longer able to be @@ -139,9 +146,9 @@ for example: <code>15 days</code>, <code>1 month</code>, <code>2 years</code>. > [Introduced](https://gitlab.com/gitlab-org/gitlab/merge_requests/18073) in GitLab 12.5. The default CI configuration file path for new projects can be set in the Admin -area of your GitLab instance (`.gitlab-ci.yml` if not set): +Area of your GitLab instance (`.gitlab-ci.yml` if not set): -1. Go to **Admin area > Settings > Continuous Integration and Deployment**. +1. Go to **Admin Area > Settings > CI/CD**. 1. Input the new path in the **Default CI configuration path** field. 1. Hit **Save changes** for the changes to take effect. @@ -162,9 +169,10 @@ but commented out to help encourage others to add to it in the future. --> ## Required pipeline configuration **(PREMIUM ONLY)** CAUTION: **Caution:** -The Required Pipeline Configuration feature is deprecated and will be removed when an -[improved compliance solution](https://gitlab.com/gitlab-org/gitlab/issues/34830) -is added to GitLab. It is recommended to avoid using this feature. +This feature is being re-evaluated in favor of a different +[compliance solution](https://gitlab.com/gitlab-org/gitlab/issues/34830). +We recommend that users who haven't yet implemented this feature wait for +the new solution. GitLab administrators can force a pipeline configuration to run on every pipeline. @@ -177,7 +185,7 @@ sourced from: To set required pipeline configuration: -1. Go to **Admin area > Settings > CI/CD**. +1. Go to **Admin Area > Settings > CI/CD**. 1. Expand the **Required pipeline configuration** section. 1. Select the required configuration from the provided dropdown. 1. Click **Save changes**. diff --git a/doc/user/admin_area/settings/external_authorization.md b/doc/user/admin_area/settings/external_authorization.md index d11f672ca5c7a7246cd42e629dca3225563a816a..da464ac1bf9312a3c1d42f20fc9f04580f503e69 100644 --- a/doc/user/admin_area/settings/external_authorization.md +++ b/doc/user/admin_area/settings/external_authorization.md @@ -40,7 +40,7 @@ Read more about logs GitLab keeps in the [omnibus documentation][omnibus-log-doc ## Configuration The external authorization service can be enabled by an admin on the GitLab's -admin area under the settings page: +**Admin Area > Settings > General** page:  @@ -62,7 +62,7 @@ The available required properties are: requesting authorization if no specific label is defined on the project When using TLS Authentication with a self signed certificate, the CA certificate -needs to be trused by the openssl installation. When using GitLab installed using +needs to be trusted by the openssl installation. When using GitLab installed using Omnibus, learn to install a custom CA in the [omnibus documentation][omnibus-ssl-docs]. Alternatively learn where to install custom certificates using `openssl version -d`. diff --git a/doc/user/admin_area/settings/help_page.md b/doc/user/admin_area/settings/help_page.md index a2c99f94d8b9c32b8bcab2acf09497ba031cbc4a..ca983edd4fa3204e9c242bc2e91b5c9dc1da6446 100644 --- a/doc/user/admin_area/settings/help_page.md +++ b/doc/user/admin_area/settings/help_page.md @@ -13,7 +13,7 @@ to go for help. You can customize and display this information on the GitLab ser You can add a help message, which will be shown on the GitLab `/help` page (e.g., <https://gitlab.com/help>) in a new section at the top of the `/help` page: -1. Navigate to **Admin area > Settings > Preferences**, then expand **Help page**. +1. Navigate to **Admin Area > Settings > Preferences**, then expand **Help page**. 1. Under **Help page text**, fill in the information you wish to display on `/help`.  @@ -27,7 +27,7 @@ You can add a help message, which will be shown on the GitLab `/help` page (e.g. You can add a help message, which will be shown on the GitLab login page in a new section titled `Need Help?`, located below the login page message: -1. Navigate to **Admin area > Settings > Preferences**, then expand **Help page**. +1. Navigate to **Admin Area > Settings > Preferences**, then expand **Help page**. 1. Under **Help text**, fill in the information you wish to display on the login page.  diff --git a/doc/user/admin_area/settings/img/bulk_push_event_v12_4.png b/doc/user/admin_area/settings/img/bulk_push_event_v12_4.png index 38e666e32ac3e46db6f5c88a97a88a18cbe9b289..114e87a61f11f67c63c91d19f8666c13960a61c1 100644 Binary files a/doc/user/admin_area/settings/img/bulk_push_event_v12_4.png and b/doc/user/admin_area/settings/img/bulk_push_event_v12_4.png differ diff --git a/doc/user/admin_area/settings/img/clone_panel_v12_4.png b/doc/user/admin_area/settings/img/clone_panel_v12_4.png index 8aa0bd2f7d8869fd14739e0d66a47b2b27b1d02b..427224f5b780e27802721c2621cdde09a33826d7 100644 Binary files a/doc/user/admin_area/settings/img/clone_panel_v12_4.png and b/doc/user/admin_area/settings/img/clone_panel_v12_4.png differ diff --git a/doc/user/admin_area/settings/img/disable_signup_v12_7.png b/doc/user/admin_area/settings/img/disable_signup_v12_7.png new file mode 100644 index 0000000000000000000000000000000000000000..be1a070a804e19348303cee07fff287ef336683a Binary files /dev/null and b/doc/user/admin_area/settings/img/disable_signup_v12_7.png differ diff --git a/doc/user/admin_area/settings/img/email_confirmation.png b/doc/user/admin_area/settings/img/email_confirmation.png deleted file mode 100644 index 987aa10c3ce22f90db53593030103a0d5560f75a..0000000000000000000000000000000000000000 Binary files a/doc/user/admin_area/settings/img/email_confirmation.png and /dev/null differ diff --git a/doc/user/admin_area/settings/img/email_confirmation_v12_7.png b/doc/user/admin_area/settings/img/email_confirmation_v12_7.png new file mode 100644 index 0000000000000000000000000000000000000000..6bcadb63b9ada44dcbfc296330f5c69a838b7ba9 Binary files /dev/null and b/doc/user/admin_area/settings/img/email_confirmation_v12_7.png differ diff --git a/doc/user/admin_area/settings/img/help_page_help_page_text_ex_v12_3.png b/doc/user/admin_area/settings/img/help_page_help_page_text_ex_v12_3.png index 9dc7ef281496821d8c22c8ab7d9fd64ebf9543ad..421fa2977f9ad83ea82108f8fc7971fd9a76c81f 100644 Binary files a/doc/user/admin_area/settings/img/help_page_help_page_text_ex_v12_3.png and b/doc/user/admin_area/settings/img/help_page_help_page_text_ex_v12_3.png differ diff --git a/doc/user/admin_area/settings/img/help_page_help_page_text_v12_3.png b/doc/user/admin_area/settings/img/help_page_help_page_text_v12_3.png index 59d3343db34ff10f0488fd6dd222a7ba112c1ba6..13c17fb118a2bc4f9948be6c902173090d4e08d9 100644 Binary files a/doc/user/admin_area/settings/img/help_page_help_page_text_v12_3.png and b/doc/user/admin_area/settings/img/help_page_help_page_text_v12_3.png differ diff --git a/doc/user/admin_area/settings/img/help_page_help_text_ex_v12_3.png b/doc/user/admin_area/settings/img/help_page_help_text_ex_v12_3.png index 9de26ac07586c82cf8a7f385c304315c12c96cd2..973be2e8b6e8de6541ca299015db83d0b640fc4d 100644 Binary files a/doc/user/admin_area/settings/img/help_page_help_text_ex_v12_3.png and b/doc/user/admin_area/settings/img/help_page_help_text_ex_v12_3.png differ diff --git a/doc/user/admin_area/settings/img/help_page_help_text_v12_3.png b/doc/user/admin_area/settings/img/help_page_help_text_v12_3.png index 1b6aad5753a7697acddd34e200a8f4494f7a49f5..8848ea55cf348965c04b0d67c6b10c67a3b25a57 100644 Binary files a/doc/user/admin_area/settings/img/help_page_help_text_v12_3.png and b/doc/user/admin_area/settings/img/help_page_help_text_v12_3.png differ diff --git a/doc/user/admin_area/settings/img/protected_paths.png b/doc/user/admin_area/settings/img/protected_paths.png index 7aa9124b8453f37d2599c464430256dc8591f83a..2233a71a1392a176d5cd0395657ad82b2b4b96e3 100644 Binary files a/doc/user/admin_area/settings/img/protected_paths.png and b/doc/user/admin_area/settings/img/protected_paths.png differ diff --git a/doc/user/admin_area/settings/img/push_event_activities_limit_v12_4.png b/doc/user/admin_area/settings/img/push_event_activities_limit_v12_4.png index fd3775ac4d79780d5bdbdb9589b7614dcdee96bb..ea618ad4c50e8fe9a8442f00e56b07b15c5d9563 100644 Binary files a/doc/user/admin_area/settings/img/push_event_activities_limit_v12_4.png and b/doc/user/admin_area/settings/img/push_event_activities_limit_v12_4.png differ diff --git a/doc/user/admin_area/settings/img/rate_limits_on_raw_endpoints.png b/doc/user/admin_area/settings/img/rate_limits_on_raw_endpoints.png index c32eb93c8a87dfa35c628369eb024882c50bf7de..c59f67df1ceec14ed85fdff4731fab54b83fa343 100644 Binary files a/doc/user/admin_area/settings/img/rate_limits_on_raw_endpoints.png and b/doc/user/admin_area/settings/img/rate_limits_on_raw_endpoints.png differ diff --git a/doc/user/admin_area/settings/index.md b/doc/user/admin_area/settings/index.md index 42f496bfbfaf859f720a65095632586a1e09384a..07d614b449bf8e5cc2849e194b73f384f01d648a 100644 --- a/doc/user/admin_area/settings/index.md +++ b/doc/user/admin_area/settings/index.md @@ -27,9 +27,9 @@ include: NOTE: **Note:** You can change the [first day of the week](../../profile/preferences.md) for the entire GitLab instance -in the **Localization** section of **Admin area > Settings > Preferences**. +in the **Localization** section of **Admin Area > Settings > Preferences**. -## GitLab.com admin area settings +## GitLab.com Admin Area settings Most of the settings under the Admin Area change the behavior of the whole GitLab instance. For GitLab.com, the admin settings are available only for the diff --git a/doc/user/admin_area/settings/instance_template_repository.md b/doc/user/admin_area/settings/instance_template_repository.md index b3a861cac45dad31246068aebefc99b2b511405a..1338352fcb102ee835f3c8e8cbb5a709b2fcbb59 100644 --- a/doc/user/admin_area/settings/instance_template_repository.md +++ b/doc/user/admin_area/settings/instance_template_repository.md @@ -17,10 +17,10 @@ while the project remains secure. ## Configuration -As an administrator, navigate to **Admin area > Settings > Templates** and +As an administrator, navigate to **Admin Area > Settings > Templates** and select the project to serve as the custom template repository. - + Once a project has been selected, you can add custom templates to the repository, and they will appear in the appropriate places in the diff --git a/doc/user/admin_area/settings/sign_in_restrictions.md b/doc/user/admin_area/settings/sign_in_restrictions.md index 0975766400fb3a6ead2be916322aa8d0ff76b846..1da93c7005ff7d15d27c3b37c57c35aa3545fdf9 100644 --- a/doc/user/admin_area/settings/sign_in_restrictions.md +++ b/doc/user/admin_area/settings/sign_in_restrictions.md @@ -40,7 +40,7 @@ this message after logging-in. To access this feature: -1. Navigate to the **Settings > General** in the Admin area. +1. Navigate to the **Admin Area > Settings > General**. 1. Expand the **Sign-in restrictions** section. <!-- ## Troubleshooting diff --git a/doc/user/admin_area/settings/sign_up_restrictions.md b/doc/user/admin_area/settings/sign_up_restrictions.md index 851a984c285d5b163391976e9d06782c73378d64..0686044b9e3f71d47ad396853134b8ba0c4d4c78 100644 --- a/doc/user/admin_area/settings/sign_up_restrictions.md +++ b/doc/user/admin_area/settings/sign_up_restrictions.md @@ -4,20 +4,42 @@ type: reference # Sign-up restrictions **(CORE ONLY)** -You can use sign-up restrictions to require user email confirmation, as well as -to blacklist or whitelist email addresses belonging to specific domains. +You can use sign-up restrictions to: ->**Note**: These restrictions are only applied during sign-up. An admin is +- Disable new signups. +- Require user email confirmation. +- Blacklist or whitelist email addresses belonging to specific domains. + +NOTE: **Note:** +These restrictions are only applied during sign-up from an external user. An admin is able to add a user through the admin panel with a disallowed domain. Also note that the users can change their email addresses after signup to disallowed domains. +## Disable new signups + +When this setting is enabled, any user visiting your GitLab domain will be able to sign up for an account. + + + +You can restrict new users from signing up by themselves for an account in your instance by disabling this setting. + +### Recommendations + +For customers running public facing GitLab instances, we highly recommend that you +consider disabling new signups if you do not expect public users to sign up for an +account. + +Alternatively, you could also consider setting up a +[whitelist](#whitelist-email-domains) or [blacklist](#blacklist-email-domains) on +email domains to prevent malicious users from creating accounts. + ## Require email confirmation You can send confirmation emails during sign-up and require that users confirm their email address before they are allowed to sign in. - + ## Minimum password length limit @@ -46,7 +68,7 @@ addresses. To access this feature: -1. Navigate to the **Settings > General** in the Admin area. +1. Navigate to the **Admin Area > Settings > General**. 1. Expand the **Sign-up restrictions** section. For the blacklist, you can enter the list manually or upload a `.txt` file that diff --git a/doc/user/admin_area/settings/usage_statistics.md b/doc/user/admin_area/settings/usage_statistics.md index 81edd9eac34b8ea9a61350be41a9b1df5298791c..eae6925d0739d43c16320565a3321ae7b8695c2d 100644 --- a/doc/user/admin_area/settings/usage_statistics.md +++ b/doc/user/admin_area/settings/usage_statistics.md @@ -7,10 +7,10 @@ type: reference GitLab Inc. will periodically collect information about your instance in order to perform various actions. -All statistics are opt-out, you can enable/disable them from the admin panel -under **Admin area > Settings > Metrics and profiling > Usage statistics**. +All statistics are opt-out. You can enable/disable them in the +**Admin Area > Settings > Metrics and profiling** section **Usage statistics**. -## Version check **(CORE ONLY)** +## Version Check **(CORE ONLY)** If enabled, version check will inform you if a new version is available and the importance of it through a status. This is shown on the help page (i.e. `/help`) @@ -31,9 +31,25 @@ patches will need to be backported, making sure active GitLab instances remain secure. If you disable version check, this information will not be collected. Enable or -disable the version check at **Admin area > Settings > Metrics and profiling > Usage statistics**. +disable the version check in **Admin Area > Settings > Metrics and profiling > Usage statistics**. + +### Request flow example + +The following example shows a basic request/response flow between the self-managed GitLab instance +and the GitLab Version Application: + +```mermaid +sequenceDiagram + participant GitLab instance + participant Version Application + GitLab instance->>Version Application: Is there a version update? + loop Version Check + Version Application->>Version Application: Record version info + end + Version Application->>GitLab instance: Response (PNG/SVG) +``` -## Usage ping **(CORE ONLY)** +## Usage Ping **(CORE ONLY)** > [Introduced][ee-557] in GitLab Enterprise Edition 8.10. More statistics [were added][ee-735] in GitLab Enterprise Edition @@ -48,11 +64,36 @@ of the instance. You can view the exact JSON payload in the administration panel. To view the payload: -1. Go to the **Admin area** (spanner symbol on the top bar). -1. Expand **Settings** in the left sidebar and click on **Metrics and profiling**. -1. Expand **Usage statistics** and click on the **Preview payload** button. - -You can see how [the usage ping data maps to different stages of the product](https://gitlab.com/gitlab-data/analytics/blob/master/transform/snowflake-dbt/data/ping_metrics_to_stage_mapping_data.csv). +1. Navigate to the **Admin Area > Settings > Metrics and profiling**. +1. Expand the **Usage statistics** section. +1. Click the **Preview payload** button. + +You can see how [the usage ping data maps to different stages of the product](https://gitlab.com/gitlab-data/analytics/blob/master/transform/snowflake-dbt/data/version_usage_stats_to_stage_mappings.csv). + +### Request flow example + +The following example shows a basic request/response flow between the self-managed GitLab instance, GitLab Version Application, +GitLab License Application and Salesforce: + +```mermaid +sequenceDiagram + participant GitLab instance + participant Version Application + participant License Application + participant Salesforce + GitLab instance->>Version Application: Usage Ping data + loop Process Usage Data + Version Application->>Version Application: Parse Usage Data + Version Application->>Version Application: Record Usage Data + Version Application->>Version Application: Update license ping time + end + Version Application-xLicense Application: Request Zuora subscription id + License Application-xVersion Application: Zuora subscription id + Version Application-xSalesforce: Request Zuora account id by Zuora subscription id + Salesforce-xVersion Application: Zuora account id + Version Application-xSalesforce: Usage data for the Zuora account + Version Application->>GitLab instance: Conversational Development Index +``` ### Deactivate the usage ping @@ -84,8 +125,8 @@ Once usage ping is enabled, GitLab will gather data from other instances and will be able to show [usage statistics](../../instance_statistics/index.md) of your instance to your users. -This can be restricted to admins by selecting "Only admins" in the Instance -Statistics visibility section under **Admin area > Settings > Metrics and profiling > Usage statistics**. +To make this visible only to admins, go to **Admin Area > Settings > Metrics and profiling**, expand +**Usage statistics**, and set the **Instance Statistics visibility** option to **Only admins**. <!-- ## Troubleshooting diff --git a/doc/user/admin_area/settings/visibility_and_access_controls.md b/doc/user/admin_area/settings/visibility_and_access_controls.md index 7439812859311e7990a2c9b81d7442d42f1717e9..b5d708b5e049ee8c2689d01b8148a6a0ae7259a8 100644 --- a/doc/user/admin_area/settings/visibility_and_access_controls.md +++ b/doc/user/admin_area/settings/visibility_and_access_controls.md @@ -16,7 +16,6 @@ To access the visibility and access control options: This global option defines the branch protection that applies to every repository's default branch. [Branch protection](../../project/protected_branches.md) specifies which roles can push to branches and which roles can delete branches. In this case _Default_ refers to a repository's default branch, which in most cases is _master_. -branches. "Default" in this case refers to a repository's default branch, which in most cases would be "master". This setting applies only to each repositories' default branch. To protect other branches, you must configure branch protection in repository. For details, see [Protected Branches](../../project/protected_branches.md). diff --git a/doc/user/analytics/code_review_analytics.md b/doc/user/analytics/code_review_analytics.md new file mode 100644 index 0000000000000000000000000000000000000000..87c29c265bf00843fa74d0df534998675a19e3e5 --- /dev/null +++ b/doc/user/analytics/code_review_analytics.md @@ -0,0 +1,35 @@ +--- +description: "Learn how long your open merge requests have spent in code review, and what distinguishes the longest-running." # Up to ~200 chars long. They will be displayed in Google Search snippets. It may help to write the page intro first, and then reuse it here. +--- + +# Code Review Analytics **(STARTER)** + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/38062) in GitLab ([Starter](https://about.gitlab.com/pricing/)) 12.7. + +Want to learn how long your open merge requests have spent in code review? Or what distinguishes your longest-running code reviews? These are some of the questions Code Review Analytics is designed to answer. + +NOTE: **Note:** +Initially no data will appear. Data will populate as users comment on open merge requests. + +## Overview + +Code Review Analytics displays a collection of merge requests in a table. These are all the open merge requests that are considered to be in code review. This feature considers code review to begin when a merge request receives its first comment from someone other than the author. The rows of the table are sorted by review time so the longest reviews appear at the top. There are also columns to display the author, approvers, comment count, and line -/+ counts. + +This feature is designed for [development team leaders](https://about.gitlab.com/handbook/marketing/product-marketing/roles-personas/#delaney-development-team-lead) and others who want to understand broad code review dynamics, and identify patterns to help explain them. You can use Code Review Analytics to expose your team's unique challenges with code review, and identify improvements that might substantially accelerate your development cycle. + +## Use cases + +Perhaps your team agrees that code review is moving too slow, or the [Cycle Analytics feature](https://docs.gitlab.com/ee/user/analytics/cycle_analytics.html) shows that "Review" is your team's most time-consuming step. You can use Code Review Analytics to see what is currently moving slowest, and analyze the patterns and trends between them. Lots of comments or commits? Maybe the code is too complex. A particular author is involved? Maybe more training is advisable. Few comments and approvers? Maybe your team is understaffed. + +## Permissions + +- On [Starter or Bronze tier](https://about.gitlab.com/pricing/) and above. +- By users with [Reporter access] and above. + +## Feature flag + +Code Review Analytics is [currently protected by a feature flag](https://gitlab.com/gitlab-org/gitlab/issues/194165) that defaults to "enabled" - meaning the feature is available. If you experience performance problems or otherwise wish to disable the feature, a GitLab administrator can execute a command in a Rails console: + +```ruby +Feature.disable(:code_review_analytics) +``` diff --git a/doc/user/analytics/cycle_analytics.md b/doc/user/analytics/cycle_analytics.md index 796cae708032e0a3e61083e2f35a026eed31dd27..dfc0f488ff95228417a44ebac581c513072771a8 100644 --- a/doc/user/analytics/cycle_analytics.md +++ b/doc/user/analytics/cycle_analytics.md @@ -44,8 +44,8 @@ There are seven stages that are tracked as part of the Cycle Analytics calculati - Time spent on code review - **Staging** (Continuous Deployment) - Time between merging and deploying to production -- **Production** (Total) - - Total lifecycle time; i.e. the velocity of the project or team +- **Total** (Total) + - Total lifecycle time. That is, the velocity of the project or team. [Previously known](https://gitlab.com/gitlab-org/gitlab/issues/38317) as **Production**. ## Date ranges @@ -60,12 +60,12 @@ GitLab provides the ability to filter analytics based on a date range. To filter ## How the data is measured Cycle Analytics records cycle time and data based on the project issues with the -exception of the staging and production stages, where only data deployed to +exception of the staging and total stages, where only data deployed to production are measured. Specifically, if your CI is not set up and you have not defined a `production` or `production/*` [environment](../../ci/yaml/README.md#environment), then you will not have any -data for those stages. +data for this stage. Each stage of Cycle Analytics is further described in the table below. @@ -77,7 +77,7 @@ Each stage of Cycle Analytics is further described in the table below. | Test | Measures the median time to run the entire pipeline for that project. It's related to the time GitLab CI takes to run every job for the commits pushed to that merge request defined in the previous stage. It is basically the start->finish time for all pipelines. | | Review | Measures the median time taken to review the merge request that has closing issue pattern, between its creation and until it's merged. | | Staging | Measures the median time between merging the merge request with closing issue pattern until the very first deployment to production. It's tracked by the environment set to `production` or matching `production/*` (case-sensitive, `Production` won't work) in your GitLab CI configuration. If there isn't a production environment, this is not tracked. | -| Production| The sum of all time (medians) taken to run the entire process, from issue creation to deploying the code to production. | +| Total | The sum of all time (medians) taken to run the entire process, from issue creation to deploying the code to production. [Previously known](https://gitlab.com/gitlab-org/gitlab/issues/38317) as **Production**. | How this works, behind the scenes: @@ -124,7 +124,7 @@ environments is configured. 1. Now that the merge request is merged, a deployment to the `production` environment starts and finishes at 19:30 (stop of **Staging** stage). 1. The cycle completes and the sum of the median times of the previous stages - is recorded to the **Production** stage. That is the time between creating an + is recorded to the **Total** stage. That is the time between creating an issue and deploying its relevant merge request to production. From the above example you can conclude the time it took each stage to complete @@ -136,10 +136,10 @@ as long as their total time: - **Test**: 5min - **Review**: 5h (19:00 - 14:00) - **Staging**: 30min (19:30 - 19:00) -- **Production**: Since this stage measures the sum of median time off all +- **Total**: Since this stage measures the sum of median time of all previous stages, we cannot calculate it if we don't know the status of the stages before. In case this is the very first cycle that is run in the project, - then the **Production** time is 10h 30min (19:30 - 09:00) + then the **Total** time is 10h 30min (19:30 - 09:00) A few notes: diff --git a/doc/user/analytics/index.md b/doc/user/analytics/index.md index 8e88d8fd7b6532ed66db3fe94db659abc8748c3c..707ba01eb7967b9246465a3cc826279b832d34ab 100644 --- a/doc/user/analytics/index.md +++ b/doc/user/analytics/index.md @@ -15,6 +15,8 @@ Once enabled, click on **Analytics** from the top navigation bar. From the centralized analytics workspace, the following analytics are available: +- [Code Review Analytics](code_review_analytics.md), enabled with the `code_review_analytics` + [feature flag](../../development/feature_flags/development.html#enabling-a-feature-flag-in-development). **(STARTER)** - [Cycle Analytics](cycle_analytics.md), enabled with the `cycle_analytics` [feature flag](../../development/feature_flags/development.html#enabling-a-feature-flag-in-development). **(PREMIUM)** - [Productivity Analytics](productivity_analytics.md), enabled with the `productivity_analytics` diff --git a/doc/user/application_security/container_scanning/index.md b/doc/user/application_security/container_scanning/index.md index 08242b3c65b226d91640c2a54678e24fafd08e85..de8854bda0ae1623d534c8e7ff3417de3d603020 100644 --- a/doc/user/application_security/container_scanning/index.md +++ b/doc/user/application_security/container_scanning/index.md @@ -269,6 +269,15 @@ it highlighted: } ], "remediations": [ + { + "fixes": [ + { + "cve": "debian:9:apt:CVE-2019-3462" + } + ], + "summary": "Upgrade apt from 1.4.8 to 1.4.9", + "diff": "YXB0LWdldCB1cGRhdGUgJiYgYXB0LWdldCB1cGdyYWRlIC15IGFwdA==" + } ] } ``` @@ -296,7 +305,7 @@ the report JSON unless stated otherwise. Presence of optional fields depends on | `vulnerabilities[].location.dependency.package.name` | Name of the package where the vulnerability is located. | | `vulnerabilities[].location.dependency.version` | Version of the vulnerable package. Optional. | | `vulnerabilities[].location.operating_system` | The operating system that contains the vulnerable package. | -| `vulnerabilities[].location.image` | The Docker image that was analyzed. Optional. | +| `vulnerabilities[].location.image` | The Docker image that was analyzed. | | `vulnerabilities[].identifiers` | An ordered array of references that identify a vulnerability on internal or external DBs. | | `vulnerabilities[].identifiers[].type` | Type of the identifier. Possible values: common identifier types (among `cve`, `cwe`, `osvdb`, and `usn`). | | `vulnerabilities[].identifiers[].name` | Name of the identifier for display purpose. | @@ -305,7 +314,11 @@ the report JSON unless stated otherwise. Presence of optional fields depends on | `vulnerabilities[].links` | An array of references to external documentation pieces or articles that describe the vulnerability further. Optional. | | `vulnerabilities[].links[].name` | Name of the vulnerability details link. Optional. | | `vulnerabilities[].links[].url` | URL of the vulnerability details document. Optional. | -| `remediations` | Not supported yet. | +| `remediations` | An array of objects containing information on cured vulnerabilities along with patch diffs to apply. Empty if no remediations provided by an underlying analyzer. | +| `remediations[].fixes` | An array of strings that represent references to vulnerabilities fixed by this particular remediation. | +| `remediations[].fixes[].cve` | A string value that describes a fixed vulnerability occurrence in the same format as `vulnerabilities[].cve`. | +| `remediations[].summary` | Overview of how the vulnerabilities have been fixed. | +| `remediations[].diff` | base64-encoded remediation code diff, compatible with [`git apply`](https://git-scm.com/docs/git-format-patch#_discussion). | ## Troubleshooting diff --git a/doc/user/application_security/dast/index.md b/doc/user/application_security/dast/index.md index 3a8a81f5f57254207678137b9d7410155eabb4e5..9678ff4de5a288d08ad997983a6ac2758fe5c0a4 100644 --- a/doc/user/application_security/dast/index.md +++ b/doc/user/application_security/dast/index.md @@ -103,6 +103,10 @@ always take the latest DAST artifact available. Behind the scenes, the [GitLab DAST Docker image](https://gitlab.com/gitlab-org/security-products/dast) is used to run the tests on the specified URL and scan it for possible vulnerabilities. +By default, the DAST template will use the latest major version of the DAST Docker image. Using the `DAST_VERSION` variable, +you can choose to automatically update DAST with new features and fixes by pinning to a major version (e.g. 1), only update fixes by pinning to a minor version (e.g. 1.6) or prevent all updates by pinning to a specific version (e.g. 1.6.4). +Find the latest DAST versions on the [Releases](https://gitlab.com/gitlab-org/security-products/dast/-/releases) page. + ### Authenticated scan It's also possible to authenticate the user before performing the DAST checks: @@ -312,6 +316,15 @@ variable value. | `DAST_FULL_SCAN_ENABLED` | no | Switches the tool to execute [ZAP Full Scan](https://github.com/zaproxy/zaproxy/wiki/ZAP-Full-Scan) instead of [ZAP Baseline Scan](https://github.com/zaproxy/zaproxy/wiki/ZAP-Baseline-Scan). Boolean. `true`, `True`, or `1` are considered as true value, otherwise false. Defaults to `false`. | | `DAST_FULL_SCAN_DOMAIN_VALIDATION_REQUIRED` | no | Requires [domain validation](#domain-validation) when running DAST full scans. Boolean. `true`, `True`, or `1` are considered as true value, otherwise false. Defaults to `false`. | +## Reports JSON format + +CAUTION: **Caution:** +The JSON report artifacts are not a public API of DAST and their format may change in the future. + +The DAST tool emits a JSON report report file. Sample report files can be found in the [DAST repository](https://gitlab.com/gitlab-org/security-products/dast/tree/master/test/end-to-end/expect). + +There are two formats of data in the JSON document that are used side by side: the proprietary ZAP format which will be eventually deprecated, and a "common" format which will be the default in the future. + ## Security Dashboard The Security Dashboard is a good place to get an overview of all the security diff --git a/doc/user/application_security/dependency_list/img/dependency_list_v12_3.png b/doc/user/application_security/dependency_list/img/dependency_list_v12_3.png index 1ae44687ed59ff69fd26f7ea4cbe61b80c221f68..8eeadb3450485e49dd9178afdc681fb9e6989da2 100644 Binary files a/doc/user/application_security/dependency_list/img/dependency_list_v12_3.png and b/doc/user/application_security/dependency_list/img/dependency_list_v12_3.png differ diff --git a/doc/user/application_security/dependency_scanning/index.md b/doc/user/application_security/dependency_scanning/index.md index 01feaaac4238aa2df7e64d0454909268c9a7c1ba..c47fd6f1ff1b31133555824512634d646122cab4 100644 --- a/doc/user/application_security/dependency_scanning/index.md +++ b/doc/user/application_security/dependency_scanning/index.md @@ -65,6 +65,7 @@ The following languages and dependency managers are supported. | Python ([poetry](https://poetry.eustace.io/)) | not currently ([issue](https://gitlab.com/gitlab-org/gitlab/issues/7006 "Support Poetry in Dependency Scanning")) | not available | | Ruby ([gem](https://rubygems.org/)) | yes | [gemnasium](https://gitlab.com/gitlab-org/security-products/gemnasium), [bundler-audit](https://github.com/rubysec/bundler-audit) | | Scala ([sbt](https://www.scala-sbt.org/)) | yes | [gemnasium](https://gitlab.com/gitlab-org/security-products/gemnasium) | +| Go ([go](https://golang.org/)) | yes (alpha) | [gemnasium](https://gitlab.com/gitlab-org/security-products/gemnasium) | ## Configuration @@ -136,6 +137,9 @@ using environment variables. | `DS_PYTHON_VERSION` | Version of Python. If set to 2, dependencies are installed using Python 2.7 instead of Python 3.6. ([Introduced](https://gitlab.com/gitlab-org/gitlab/issues/12296) in GitLab 12.1)| | `DS_PIP_VERSION` | Force the install of a specific pip version (example: `"19.3"`), otherwise the pip installed in the docker image is used. | | `DS_PIP_DEPENDENCY_PATH` | Path to load Python pip dependencies from. ([Introduced](https://gitlab.com/gitlab-org/gitlab/issues/12412) in GitLab 12.2) | +| `GEMNASIUM_DB_LOCAL_PATH` | Path to local gemnasium database (default `/gemnasium-db`). +| `GEMNASIUM_DB_REMOTE_URL` | Repository URL for fetching the gemnasium database (default `https://gitlab.com/gitlab-org/security-products/gemnasium-db.git`). +| `GEMNASIUM_DB_REF_NAME` | Branch name for remote repository database (default `master`). `GEMNASIUM_DB_REMOTE_URL` is required. | `DS_DEFAULT_ANALYZERS` | Override the names of the official default images. Read more about [customizing analyzers](analyzers.md). | | `DS_DISABLE_DIND` | Disable Docker in Docker and run analyzers [individually](#disabling-docker-in-docker-for-dependency-scanning).| | `DS_PULL_ANALYZER_IMAGES` | Pull the images from the Docker registry (set to `0` to disable). | @@ -146,7 +150,7 @@ using environment variables. | `PIP_INDEX_URL` | Base URL of Python Package Index (default `https://pypi.org/simple`). | | `PIP_EXTRA_INDEX_URL` | Array of [extra URLs](https://pip.pypa.io/en/stable/reference/pip_install/#cmdoption-extra-index-url) of package indexes to use in addition to `PIP_INDEX_URL`. Comma separated. | | `PIP_REQUIREMENTS_FILE` | Pip requirements file to be scanned. | -| `MAVEN_CLI_OPTS` | List of command line arguments that will be passed to the maven analyzer during the project's build phase (see example for [using private repos](#using-private-maven-repos)). | +| `MAVEN_CLI_OPTS` | List of command line arguments that will be passed to `maven` by the analyzer. The default is `"-DskipTests --batch-mode"`. See an example for [using private repos](#using-private-maven-repos). | | `BUNDLER_AUDIT_UPDATE_DISABLED` | Disable automatic updates for the `bundler-audit` analyzer (default: `"false"`). Useful if you're running Dependency Scanning in an offline, air-gapped environment.| ### Using private Maven repos diff --git a/doc/user/application_security/license_compliance/img/license_list_v12_6.png b/doc/user/application_security/license_compliance/img/license_list_v12_6.png new file mode 100644 index 0000000000000000000000000000000000000000..8f2b510be0d6e04429cbb72ffc5feeeba26b3532 Binary files /dev/null and b/doc/user/application_security/license_compliance/img/license_list_v12_6.png differ diff --git a/doc/user/application_security/license_compliance/index.md b/doc/user/application_security/license_compliance/index.md index 3cf8301adcaeed1136d0ebd8bf4a460a6816df4c..97804a451b9736bc6c67351785eda9d4e03f963a 100644 --- a/doc/user/application_security/license_compliance/index.md +++ b/doc/user/application_security/license_compliance/index.md @@ -253,3 +253,25 @@ questions that you know someone might ask. Each scenario can be a third-level heading, e.g. `### Getting error message X`. If you have none to add when creating a doc, leave this section in place but commented out to help encourage others to add to it in the future. --> + +## License list + +> [Introduced](https://gitlab.com/gitlab-org/gitlab-ee/issues/13582) in [GitLab Ultimate](https://about.gitlab.com/pricing/) 12.7. + +The License list allows you to see your project's licenses and key +details about them. + +In order for the licenses to appear under the license list, the following +requirements must be met: + +1. The License Compliance CI job must be [configured](#configuration) for your project. +1. Your project must use at least one of the + [supported languages and package managers](#supported-languages-and-package-managers). + +Once everything is set, navigate to **Security & Compliance > License Compliance** +in your project's sidebar, and you'll see the licenses displayed, where: + +- **Name:** The name of the license. +- **Component:** The components which have this license. + + diff --git a/doc/user/application_security/sast/index.md b/doc/user/application_security/sast/index.md index 95027e99c00a4b789a8707886fcc508b049741e9..2672b0f3461351f055e04313d2e52736624bf303 100644 --- a/doc/user/application_security/sast/index.md +++ b/doc/user/application_security/sast/index.md @@ -100,7 +100,7 @@ Add the following to your `.gitlab-ci.yml` file: ```yaml include: - template: SAST.gitlab-ci.yml + - template: SAST.gitlab-ci.yml ``` The included template will create a `sast` job in your CI/CD pipeline and scan @@ -124,7 +124,7 @@ set the `SAST_GOSEC_LEVEL` variable to `2`: ```yaml include: - template: SAST.gitlab-ci.yml + - template: SAST.gitlab-ci.yml variables: SAST_GOSEC_LEVEL: 2 @@ -141,7 +141,7 @@ template inclusion and specify any additional keys under it. For example: ```yaml include: - template: SAST.gitlab-ci.yml + - template: SAST.gitlab-ci.yml sast: variables: @@ -178,7 +178,7 @@ This does not require running the executor in privileged mode. For example: ```yaml include: - template: SAST.gitlab-ci.yml + - template: SAST.gitlab-ci.yml variables: SAST_DISABLE_DIND: "true" @@ -196,12 +196,63 @@ kubesec analyzer. In `.gitlab-ci.yml`, define: ```yaml include: - template: SAST.gitlab-ci.yml + - template: SAST.gitlab-ci.yml variables: + SAST_DISABLE_DIND: "true" SCAN_KUBERNETES_MANIFESTS: "true" ``` +#### Pre-compilation + +If your project requires custom build configurations, it can be preferable to avoid +compilation during your SAST execution and instead pass all job artifacts from an +earlier stage within the pipeline. + +To pass your project's dependencies as artifacts, the dependencies must be included +in the project's working directory and specified using the `artifacts:path` configuration. +If all dependencies are present, the `-compile=false` flag can be provided to the +analyzer and compilation will be skipped: + +```yaml +image: maven:3.6-jdk-8-alpine + +stages: + - build + - test + +include: + template: SAST.gitlab-ci.yml + +variables: + SAST_DISABLE_DIND: "true" + +build: + stage: build + script: + - mvn package -Dmaven.repo.local=./.m2/repository + artifacts: + paths: + - .m2/ + - target/ + +spotbugs-sast: + dependencies: build + script: + - /analyzer run -compile=false + variables: + MAVEN_REPO_PATH: ./.m2/repository + artifacts: + reports: + sast: gl-sast-report.json +``` + +NOTE: **Note:** +The path to the vendored directory must be specified explicitly to allow +the analyzer to recognize the compiled artifacts. This configuration can vary per +analyzer but in the case of Java above, `MAVEN_REPO_PATH` can be used. +See [Analyzer settings](#analyzer-settings) for the complete list of available options. + ### Available variables SAST can be [configured](#customizing-the-sast-settings) using environment variables. diff --git a/doc/user/application_security/security_dashboard/img/instance_security_dashboard_link_v12_4.png b/doc/user/application_security/security_dashboard/img/instance_security_dashboard_link_v12_4.png new file mode 100644 index 0000000000000000000000000000000000000000..e0e80810b082e068d00a3e13a44e97b0131cad6b Binary files /dev/null and b/doc/user/application_security/security_dashboard/img/instance_security_dashboard_link_v12_4.png differ diff --git a/doc/user/application_security/security_dashboard/img/instance_security_dashboard_with_projects_v12_7.png b/doc/user/application_security/security_dashboard/img/instance_security_dashboard_with_projects_v12_7.png new file mode 100644 index 0000000000000000000000000000000000000000..ffd6b0bfae61e05eeac91489798364ef4ac30d11 Binary files /dev/null and b/doc/user/application_security/security_dashboard/img/instance_security_dashboard_with_projects_v12_7.png differ diff --git a/doc/user/application_security/security_dashboard/index.md b/doc/user/application_security/security_dashboard/index.md index bb2bf0b78060dc0b8a123446fa6b58d4128f4175..e9ae87ab44e7e7f3bbabf886fd40ad23e61521da 100644 --- a/doc/user/application_security/security_dashboard/index.md +++ b/doc/user/application_security/security_dashboard/index.md @@ -26,7 +26,7 @@ The Security Dashboard supports the following reports: ## Requirements -To use the group, project or pipeline security dashboard: +To use the instance, group, project or pipeline security dashboard: 1. At least one project inside a group must be configured with at least one of the [supported reports](#supported-reports). @@ -110,6 +110,31 @@ vulnerabilities are not included either. Read more on how to [interact with the vulnerabilities](../index.md#interacting-with-the-vulnerabilities). +## Instance Security Dashboard + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/6953) in [GitLab Ultimate](https://about.gitlab.com/pricing/) 12.7. + +At the instance level, the Security Dashboard displays the vulnerabilities +present in all of the projects that you have added to it. + +You can access the Instance Security Dashboard from the menu +bar at the top of the page. Under **More**, select **Security**. + + + +### Adding projects to the dashboard + +To add projects to the dashboard: + +1. Click the **Edit dashboard** button on the Instance Security Dashboard page. +1. Search for and add one or more projects using the **Search your projects** field. +1. Click the **Add projects** button. + +Once added, the dashboard will display the vulnerabilities found in your chosen +projects. + + + ## Keeping the dashboards up to date The Security Dashboard displays information from the results of the most recent diff --git a/doc/user/clusters/applications.md b/doc/user/clusters/applications.md index 95dbe7d3b518f8580d99325053e9a98ae78cad2f..47d835a1622f7f6551aa32519620f4251686b5d2 100644 --- a/doc/user/clusters/applications.md +++ b/doc/user/clusters/applications.md @@ -35,12 +35,13 @@ The following applications can be installed: - [Helm](#helm) - [Ingress](#ingress) -- [Cert-Manager](#cert-manager) +- [cert-manager](#cert-manager) - [Prometheus](#prometheus) - [GitLab Runner](#gitlab-runner) - [JupyterHub](#jupyterhub) - [Knative](#knative) - [Crossplane](#crossplane) +- [Elastic Stack](#elastic-stack) With the exception of Knative, the applications will be installed in a dedicated namespace called `gitlab-managed-apps`. @@ -73,13 +74,13 @@ Installing Helm as a GitLab-managed App behind a proxy is not supported, but a [workaround](../../topics/autodevops/index.md#installing-helm-behind-a-proxy) is available. -### Cert-Manager +### cert-manager > Introduced in GitLab 11.6 for project- and group-level clusters. -[Cert-Manager](https://docs.cert-manager.io/en/latest/) is a native +[cert-manager](https://docs.cert-manager.io/en/latest/) is a native Kubernetes certificate management controller that helps with issuing -certificates. Installing Cert-Manager on your cluster will issue a +certificates. Installing cert-manager on your cluster will issue a certificate by [Let's Encrypt](https://letsencrypt.org/) and ensure that certificates are valid and up-to-date. @@ -91,13 +92,13 @@ The chart used to install this application depends on the version of GitLab used - GitLab 12.2 and older, the [stable/cert-manager](https://github.com/helm/charts/tree/master/stable/cert-manager) chart was used. -If you have installed Cert-Manager prior to GitLab 12.3, Let's Encrypt will -[block requests from older versions of Cert-Manager](https://community.letsencrypt.org/t/blocking-old-cert-manager-versions/98753). +If you have installed cert-manager prior to GitLab 12.3, Let's Encrypt will +[block requests from older versions of cert-manager](https://community.letsencrypt.org/t/blocking-old-cert-manager-versions/98753). To resolve this: -1. Uninstall Cert-Manager (consider [backing up any additional configuration](https://docs.cert-manager.io/en/latest/tasks/backup-restore-crds.html)). -1. Install Cert-Manager again. +1. Uninstall cert-manager (consider [backing up any additional configuration](https://docs.cert-manager.io/en/latest/tasks/backup-restore-crds.html)). +1. Install cert-manager again. ### GitLab Runner @@ -248,10 +249,10 @@ use an A record. If your external endpoint is a hostname, use a CNAME record. #### Web Application Firewall (ModSecurity) -> [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/issues/65192) in GitLab 12.3 (enabled using `ingress_modsecurity` [feature flag](../../development/feature_flags/development.md#enabling-a-feature-flag-in-development)). +> [Introduced](https://gitlab.com/gitlab-org/gitlab/merge_requests/21966) in GitLab 12.7. Out of the box, GitLab provides you real-time security monitoring with -[`modsecurity`](https://kubernetes.github.io/ingress-nginx/user-guide/nginx-configuration/annotations/#modsecurity) +[ModSecurity](https://kubernetes.github.io/ingress-nginx/user-guide/nginx-configuration/annotations/#modsecurity). Modsecurity is a toolkit for real-time web application monitoring, logging, and access control. With GitLab's offering, the [OWASP's Core Rule Set](https://www.modsecurity.org/CRS/Documentation/), which provides generic attack detection capabilities, @@ -264,25 +265,21 @@ This feature: For example: ```sh - kubectl -n gitlab-managed-apps exec -it $(kubectl get pods -n gitlab-managed-apps | grep 'ingress-controller' | awk '{print $1}') -- tail -f /var/log/modsec/audit.log + kubectl logs -n gitlab-managed-apps $(kubectl get pod -n gitlab-managed-apps -l app=nginx-ingress,component=controller --no-headers=true -o custom-columns=:metadata.name) modsecurity-log -f ``` -There is a small performance overhead by enabling `modsecurity`. If this is -considered significant for your application, you can either: +To enable ModSecurity, check the **Enable Web Application Firewall** checkbox +when installing your [Ingress application](#ingress). -- Disable ModSecurity's rule engine for your deployed application by setting - [the deployment variable](../../topics/autodevops/index.md) - `AUTO_DEVOPS_MODSECURITY_SEC_RULE_ENGINE` to `Off`. This will prevent ModSecurity from - processing any requests for the given application or environment. -- Toggle the feature flag to false by running the following command within your - instance's Rails console: +There is a small performance overhead by enabling ModSecurity. If this is +considered significant for your application, you can disable ModSecurity's +rule engine for your deployed application by setting +[the deployment variable](../../topics/autodevops/index.md) +`AUTO_DEVOPS_MODSECURITY_SEC_RULE_ENGINE` to `Off`. This will prevent ModSecurity +from processing any requests for the given application or environment. - ```ruby - Feature.disable(:ingress_modsecurity) - ``` - -Once disabled, you must [uninstall](#uninstalling-applications) and reinstall your Ingress -application for the changes to take effect. +To permanently disable it, you must [uninstall](#uninstalling-applications) and +reinstall your Ingress application for the changes to take effect. ### JupyterHub @@ -423,18 +420,45 @@ install Crossplane using the [`values.yaml`](https://github.com/crossplaneio/crossplane/blob/master/cluster/charts/crossplane/values.yaml.tmpl) file. -#### Enabling installation +### Elastic Stack + +> Introduced in GitLab 12.7 for project- and group-level clusters. + +[Elastic Stack](https://www.elastic.co/products/elastic-stack) is a complete end-to-end +log analysis solution which helps in deep searching, analyzing and visualizing the logs +generated from different machines. + +GitLab is able to gather logs from pods in your cluster automatically. +Filebeat will run as a DaemonSet on each node in your cluster, and it will ship container logs to Elasticsearch for querying. +GitLab will then connect to Elasticsearch for logs instead of the Kubernetes API, +and you will have access to more advanced querying capabilities. + +Log data is automatically deleted after 15 days using [Curator](https://www.elastic.co/guide/en/elasticsearch/client/curator/5.5/about.html). -This is a preliminary release of Crossplane as a GitLab-managed application. By default, +This is a preliminary release of Elastic Stack as a GitLab-managed application. By default, the ability to install it is disabled. -To allow installation of Crossplane as a GitLab-managed application, ask a GitLab +To allow installation of Elastic Stack as a GitLab-managed application, ask a GitLab administrator to run following command within a Rails console: ```ruby -Feature.enable(:enable_cluster_application_crossplane) +Feature.enable(:enable_cluster_application_elastic_stack) ``` +Once the feature flag is set, to enable log shipping, install Elastic Stack into the cluster with the +**Install** button. + +NOTE: **Note:** +The [`stable/elastic-stack`](https://github.com/helm/charts/tree/master/stable/elastic-stack) +chart is used to install this application with a +[`values.yaml`](https://gitlab.com/gitlab-org/gitlab/blob/master/vendor/elastic_stack/values.yaml) +file. + +NOTE: **Note:** +The chart will deploy 4 Elasticsearch nodes: 2 masters, 1 data and 1 client node, +with resource requests totalling 0.1 CPU and 3GB RAM. Each data node requests 1.5GB of memory, +which makes it incompatible with clusters of `f1-micro` and `g1-small` instance types. + ## Install using GitLab CI (alpha) > [Introduced](https://gitlab.com/gitlab-org/gitlab/merge_requests/20822) in GitLab 12.6. @@ -450,10 +474,16 @@ install using Helm `values.yaml` files. Supported applications: - [Ingress](#install-ingress-using-gitlab-ci) +- [cert-manager](#install-cert-manager-using-gitlab-ci) - [Sentry](#install-sentry-using-gitlab-ci) +- [GitLab Runner](#install-gitlab-runner-using-gitlab-ci) ### Usage +You can find and import all the files referenced below +in the [example cluster applications +project](https://gitlab.com/gitlab-org/cluster-integration/example-cluster-applications/). + To install applications using GitLab CI: 1. Connect the cluster to a [cluster management project](management_project.md). @@ -478,7 +508,10 @@ To install applications using GitLab CI: customize values for the installed application. A GitLab CI pipeline will then run on the `master` branch to install the -applications you have configured. +applications you have configured. In case of pipeline failure, the +output of the [Helm +Tiller](https://v2.helm.sh/docs/install/#running-tiller-locally) binary +will be saved as a [CI job artifact](../project/pipelines/job_artifacts.md). ### Install Ingress using GitLab CI @@ -499,6 +532,43 @@ management project. Refer to the [chart](https://github.com/helm/charts/tree/master/stable/nginx-ingress) for the available configuration options. +### Install cert-manager using GitLab CI + +cert-manager is installed using GitLab CI by defining configuration in +`.gitlab/managed-apps/config.yaml`. + +cert-manager: + +- Is installed into the `gitlab-managed-apps` namespace of your cluster. +- Can be installed with or without a default [Let's Encrypt `ClusterIssuer`](https://cert-manager.io/docs/configuration/acme/), which requires an + email address to be specified. The email address is used by Let's Encrypt to + contact you about expiring certificates and issues related to your account. + +The following configuration is required to install cert-manager using GitLab CI: + +```yaml +certManager: + installed: true + letsEncryptClusterIssuer: + installed: true + email: "user@example.com" +``` + +The following installs cert-manager using GitLab CI without the default `ClusterIssuer`: + +```yaml +certManager: + installed: true + letsEncryptClusterIssuer: + installed: false +``` + +You can customize the installation of cert-manager by defining +`.gitlab/managed-apps/cert-manager/values.yaml` file in your cluster +management project. Refer to the +[chart](https://hub.helm.sh/charts/jetstack/cert-manager) for the +available configuration options. + ### Install Sentry using GitLab CI NOTE: **Note:** @@ -560,6 +630,37 @@ postgresql: postgresqlPassword: example-postgresql-password ``` +### Install GitLab Runner using GitLab CI + +GitLab Runner is installed using GitLab CI by defining configuration in +`.gitlab/managed-apps/config.yaml`. + +The following configuration is required to install GitLab Runner using GitLab CI: + +```yaml +gitlabRunner: + installed: true +``` + +GitLab Runner is installed into the `gitlab-managed-apps` namespace of your cluster. + +In order for GitLab Runner to function, you **must** specify the following: + +- `gitlabUrl` - the GitLab server full URL (e.g., `https://example.gitlab.com`) to register the Runner against. +- `runnerRegistrationToken` - The registration token for adding new Runners to GitLab. This must be + [retrieved from your GitLab instance](../../ci/runners/README.md). + +These values can be specifed using [CI variables](../../ci/variables/README.md): + +- `GITLAB_RUNNER_GITLAB_URL` will be used for `gitlabUrl`. +- `GITLAB_RUNNER_REGISTRATION_TOKEN` will be used for `runnerRegistrationToken` + +You can customize the installation of GitLab Runner by defining +`.gitlab/managed-apps/gitlab-runner/values.yaml` file in your cluster +management project. Refer to the +[chart](https://gitlab.com/gitlab-org/charts/gitlab-runner) for the +available configuration options. + ## Upgrading applications > [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/24789) in GitLab 11.8. @@ -593,7 +694,7 @@ The applications below can be uninstalled. | Application | GitLab version | Notes | | ----------- | -------------- | ----- | -| Cert-Manager | 12.2+ | The associated private key will be deleted and cannot be restored. Deployed applications will continue to use HTTPS, but certificates will not be renewed. Before uninstalling, you may wish to [back up your configuration](https://docs.cert-manager.io/en/latest/tasks/backup-restore-crds.html) or [revoke your certificates](https://letsencrypt.org/docs/revoking/). | +| cert-manager | 12.2+ | The associated private key will be deleted and cannot be restored. Deployed applications will continue to use HTTPS, but certificates will not be renewed. Before uninstalling, you may wish to [back up your configuration](https://docs.cert-manager.io/en/latest/tasks/backup-restore-crds.html) or [revoke your certificates](https://letsencrypt.org/docs/revoking/). | | GitLab Runner | 12.2+ | Any running pipelines will be canceled. | | Helm | 12.2+ | The associated Tiller pod, the `gitlab-managed-apps` namespace, and all of its resources will be deleted and cannot be restored. | | Ingress | 12.1+ | The associated load balancer and IP will be deleted and cannot be restored. Furthermore, it can only be uninstalled if JupyterHub is not installed. | @@ -601,6 +702,7 @@ The applications below can be uninstalled. | Knative | 12.1+ | The associated IP will be deleted and cannot be restored. | | Prometheus | 11.11+ | All data will be deleted and cannot be restored. | | Crossplane | 12.5+ | All data will be deleted and cannot be restored. | +| Elastic Stack | 12.7+ | All data will be deleted and cannot be restored. | | Sentry | 12.6+ | The PostgreSQL persistent volume will remain and should be manually removed for complete uninstall. | To uninstall an application: diff --git a/doc/user/clusters/crossplane.md b/doc/user/clusters/crossplane.md index ee0bd4c33dbc2694a8b800ac8e27e036a58f58ed..247a373301fbafcb5e4ce43ab9cf72680fd5ef40 100644 --- a/doc/user/clusters/crossplane.md +++ b/doc/user/clusters/crossplane.md @@ -222,7 +222,7 @@ The Auto DevOps pipeline can be run with the following options: The Environment variables, `AUTO_DEVOPS_POSTGRES_MANAGED` and `AUTO_DEVOPS_POSTGRES_MANAGED_CLASS_SELECTOR` need to be set to provision PostgreSQL using Crossplane -Alertnatively, the following options can be overridden from the values for the Helm chart. +Alternatively, the following options can be overridden from the values for the Helm chart. - `postgres.managed` set to true which will select a default resource class. The resource class needs to be marked with the annotation @@ -235,7 +235,7 @@ Alertnatively, the following options can be overridden from the values for the H will select the CloudSQLInstance class `cloudsqlinstancepostgresql-standard` to satisfy the claim request. -The Auto DevOps pipeline should provision a PostgresqlInstance when it runs succesfully. +The Auto DevOps pipeline should provision a PostgresqlInstance when it runs successfully. Verify creation of the PostgreSQL Instance. diff --git a/doc/user/clusters/img/advanced-settings-cluster-management-project-v12_5.png b/doc/user/clusters/img/advanced-settings-cluster-management-project-v12_5.png index 63e2d1cd4e876aa1bac2587560a8fab8edf09cd8..5fd1bac5e0502fd32bd645bc9c2c1aaff9cd2c58 100644 Binary files a/doc/user/clusters/img/advanced-settings-cluster-management-project-v12_5.png and b/doc/user/clusters/img/advanced-settings-cluster-management-project-v12_5.png differ diff --git a/doc/user/clusters/img/cluster_environments_table_v12_3.png b/doc/user/clusters/img/cluster_environments_table_v12_3.png index 52f232e2eb33b19cf60e8540cf8194a3641ac347..9e419efe4c509ece59fd689a4cc5224dc67e0310 100644 Binary files a/doc/user/clusters/img/cluster_environments_table_v12_3.png and b/doc/user/clusters/img/cluster_environments_table_v12_3.png differ diff --git a/doc/user/discussions/img/apply_suggestion_v12_7.png b/doc/user/discussions/img/apply_suggestion_v12_7.png new file mode 100644 index 0000000000000000000000000000000000000000..c8c44149fd0e92574a1f370026ed29fff4b0d9f3 Binary files /dev/null and b/doc/user/discussions/img/apply_suggestion_v12_7.png differ diff --git a/doc/user/discussions/img/insert_suggestion.png b/doc/user/discussions/img/insert_suggestion.png deleted file mode 100644 index 4bf293b8297650eaa69735236bfafda7d575a797..0000000000000000000000000000000000000000 Binary files a/doc/user/discussions/img/insert_suggestion.png and /dev/null differ diff --git a/doc/user/discussions/img/make_suggestion.png b/doc/user/discussions/img/make_suggestion.png deleted file mode 100644 index a24e29770aa8ddc6032f6cc67bd9d4a161222aa4..0000000000000000000000000000000000000000 Binary files a/doc/user/discussions/img/make_suggestion.png and /dev/null differ diff --git a/doc/user/discussions/img/make_suggestion_v12_7.png b/doc/user/discussions/img/make_suggestion_v12_7.png new file mode 100644 index 0000000000000000000000000000000000000000..7eb84186b0a934d8dc378823c5cfc9fb55335833 Binary files /dev/null and b/doc/user/discussions/img/make_suggestion_v12_7.png differ diff --git a/doc/user/discussions/img/suggestion.png b/doc/user/discussions/img/suggestion.png deleted file mode 100644 index f7962305a15c25c5ffd45b6309bcc45c3f3734ba..0000000000000000000000000000000000000000 Binary files a/doc/user/discussions/img/suggestion.png and /dev/null differ diff --git a/doc/user/discussions/img/suggestion_button_v12_7.png b/doc/user/discussions/img/suggestion_button_v12_7.png new file mode 100644 index 0000000000000000000000000000000000000000..3b7a4d625a343795ddd30cdf9ec6b9795051074c Binary files /dev/null and b/doc/user/discussions/img/suggestion_button_v12_7.png differ diff --git a/doc/user/discussions/img/suggestions_custom_commit_messages_v12_7.png b/doc/user/discussions/img/suggestions_custom_commit_messages_v12_7.png new file mode 100644 index 0000000000000000000000000000000000000000..8bbc0fcb99b7aff1eb847ef29a24d4b4ae9c0203 Binary files /dev/null and b/doc/user/discussions/img/suggestions_custom_commit_messages_v12_7.png differ diff --git a/doc/user/discussions/index.md b/doc/user/discussions/index.md index d4e485d7c32ded01e234fd03c24123b675bc3ac6..6016837a7699bdf469681cb7253778c3e9e85493 100644 --- a/doc/user/discussions/index.md +++ b/doc/user/discussions/index.md @@ -4,12 +4,12 @@ The ability to contribute conversationally is offered throughout GitLab. You can leave a comment in the following places: -- issues -- epics **(ULTIMATE)** -- merge requests -- snippets -- commits -- commit diffs +- Issues +- Epics **(ULTIMATE)** +- Merge requests +- Snippets +- Commits +- Commit diffs There are standard comments, and you also have the option to create a comment in the form of a thread. A comment can also be [turned into a thread](#start-a-thread-by-replying-to-a-standard-comment) @@ -29,9 +29,7 @@ There is a limit of 5,000 comments for every object, for example: issue, epic, a ## Resolvable comments and threads -> **Notes:** -> -> - The main feature was [introduced][ce-5022] in GitLab 8.11. +> - [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/5022) in GitLab 8.11. > - Resolvable threads can be added only to merge request diffs. Thread resolution helps keep track of progress during planning or code review. @@ -352,7 +350,10 @@ bottom of the screen with two buttons: Clicking **Submit review** will publish all comments. Any quick actions submitted are performed at this time. -Alternatively, every pending comment has a button to finish the entire review. +Alternatively, to finish the entire review from a pending comment: + +- Click the **Finish review** button on the comment. +- Use the `/submit_review` [quick action](../project/quick_actions.md) in the text of non-review comment.  @@ -389,47 +390,45 @@ As a reviewer, you're able to suggest code changes with a simple Markdown syntax in Merge Request Diff threads. Then, the Merge Request author (or other users with appropriate [permission](../permissions.md)) is able to apply these -suggestions with a click, which will generate a commit in -the Merge Request authored by the user that applied them. +Suggestions with a click, which will generate a commit in +the merge request authored by the user that applied them. 1. Choose a line of code to be changed, add a new comment, then click on the **Insert suggestion** icon in the toolbar: -  +  1. In the comment, add your suggestion to the pre-populated code block: -  +  1. Click **Comment**. - The suggestions in the comment can be applied by the merge request author + NOTE: **Note:** + If you're using GitLab Premium, GitLab.com Silver, and higher tiers, the thread will display [Review](#merge-request-reviews-premium) options. Click either **Start a review**, **Add comment now**, or **Add to review** to obtain the same result. + + The Suggestion in the comment can be applied by the merge request author directly from the merge request: -  +  -Once the author applies a suggestion, it will be marked with the **Applied** label, +Once the author applies a Suggestion, it will be marked with the **Applied** label, the thread will be automatically resolved, and GitLab will create a new commit -with the message `Apply suggestion to <file-name>` and push the suggested change -directly into the codebase in the merge request's branch. -[Developer permission](../permissions.md) is required to do so. - -> **Note:** -Custom commit messages will be introduced by -[#54404](https://gitlab.com/gitlab-org/gitlab-foss/issues/54404). +and push the suggested change directly into the codebase in the merge request's +branch. [Developer permission](../permissions.md) is required to do so. -### Multi-line suggestions +### Multi-line Suggestions > [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/issues/53310) in GitLab 11.10. -Reviewers can also suggest changes to multiple lines with a single suggestion -within Merge Request diff threads by adjusting the range offsets. The +Reviewers can also suggest changes to multiple lines with a single Suggestion +within merge request diff threads by adjusting the range offsets. The offsets are relative to the position of the diff thread, and specify the range to be replaced by the suggestion when it is applied.  -In the example above, the suggestion covers three lines above and four lines +In the example above, the Suggestion covers three lines above and four lines below the commented line. When applied, it would replace from 3 lines _above_ to 4 lines _below_ the commented line, with the suggested change. @@ -440,6 +439,37 @@ Suggestions covering multiple lines are limited to 100 lines _above_ and 100 lines _below_ the commented diff line, allowing up to 200 changed lines per suggestion. +### Configure the commit message for applied Suggestions + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/13086) in GitLab 12.7. + +GitLab uses `Apply suggestion to %{file_path}` by default as commit messages +when applying Suggestions. This commit message can be customized to +follow any guidelines you might have. To do so, expand the **Merge requests** +tab within your project's **General** settings and change the +**Merge suggestions** text: + + + +You can also use following variables besides static text: + +| Variable | Description | Output example | +|---|---|---| +| `%{project_path}` | The project path. | `my-group/my-project` | +| `%{project_name}` | The human-readable name of the project. | **My Project** | +| `%{file_path}` | The path of the file the Suggestion is applied to. | `docs/index.md` | +| `%{branch_name}` | The name of the branch the Suggestion is applied on. | `my-feature-branch` | +| `%{username}` | The username of the user applying the Suggestion. | `user_1` | +| `%{user_full_name}` | The full name of the user applying the Suggestion. | **User 1** | + +For example, to customize the commit message to output +**Addresses user_1's review**, set the custom text to +`Addresses %{username}'s review`. + +NOTE: **Note:** +Custom commit messages for each applied Suggestion will be +introduced by [#25381](https://gitlab.com/gitlab-org/gitlab/issues/25381). + ## Start a thread by replying to a standard comment > [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/issues/30299) in GitLab 11.9 @@ -461,7 +491,6 @@ to the original comment, so a note about when it was last edited will appear und This feature only exists for Issues, Merge requests, and Epics. Commits, Snippets and Merge request diff threads are not supported yet. -[ce-5022]: https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/5022 [ce-7125]: https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/7125 [ce-7527]: https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/7527 [ce-7180]: https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/7180 diff --git a/doc/user/group/clusters/index.md b/doc/user/group/clusters/index.md index 2b36c3bdf5b11cb0bcaac7196f8598fdea4de2b1..83d1fc672df1eb3c37742225f0dcd010367f9072 100644 --- a/doc/user/group/clusters/index.md +++ b/doc/user/group/clusters/index.md @@ -53,8 +53,8 @@ differentiate the new cluster from the rest. ## GitLab-managed clusters -> [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/22011) in GitLab 11.5. -> Became [optional](https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/26565) in GitLab 11.11. +> - [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/22011) in GitLab 11.5. +> - Became [optional](https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/26565) in GitLab 11.11. You can choose to allow GitLab to manage your cluster for you. If your cluster is managed by GitLab, resources for your projects will be automatically created. See the @@ -170,6 +170,11 @@ For important information about securely configuring GitLab Runners, see Runners](../../project/clusters/add_remove_clusters.md#security-of-gitlab-runners) documentation for project-level clusters. +## More information + +For information on integrating GitLab and Kubernetes, see +[Kubernetes clusters](../../project/clusters/index.md). + <!-- ## Troubleshooting Include any troubleshooting steps that you can foresee. If you know beforehand what issues diff --git a/doc/user/group/epics/img/epics_list_view_v12.5.png b/doc/user/group/epics/img/epics_list_view_v12.5.png index 2520ee67abc02a1916b15221bbaaad784a8236ce..6e3c39009be357e135db11ca18ba7024b83b014f 100644 Binary files a/doc/user/group/epics/img/epics_list_view_v12.5.png and b/doc/user/group/epics/img/epics_list_view_v12.5.png differ diff --git a/doc/user/group/epics/index.md b/doc/user/group/epics/index.md index 01e277d5559d8bbadc59eb75a10b5f2d6c3bab87..d8cb49d0e9f43ea665ba8f1155dc52c0cb4f3d73 100644 --- a/doc/user/group/epics/index.md +++ b/doc/user/group/epics/index.md @@ -40,14 +40,19 @@ An epic's page contains the following tabs: - **Epics and Issues**: epics and issues added to this epic. Child epics, and their issues, are shown in a tree view. - Click on the <kbd>></kbd> beside a parent epic to reveal the child epics and issues. + - Hover over the total counts to see a breakdown of open and closed items. - **Roadmap**: a roadmap view of child epics which have start and due dates.  ## Adding an issue to an epic -Any issue that belongs to a project in the epic's group, or any of the epic's -subgroups, are eligible to be added. New issues appear at the top of the list of issues in the **Epics and Issues** tab. +You can add an existing issue to an epic, or, from an epic's page, create a new issue that is automatically added to the epic. + +### Adding an existing issue to an epic + +Existing issues that belong to a project in an epic's group, or any of the epic's +subgroups, are eligible to be added to the epic. Newly added issues appear at the top of the list of issues in the **Epics and Issues** tab. An epic contains a list of issues and an issue can be associated with at most one epic. When you add an issue that is already linked to an epic, @@ -63,6 +68,19 @@ To add an issue to an epic: If there are multiple issues to be added, press <kbd>Spacebar</kbd> and repeat this step. 1. Click **Add**. +### Creating an issue from an epic + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/5419) in GitLab 12.7. + +Creating an issue from an epic enables you to maintain focus on the broader context of the epic while dividing work into smaller parts. + +To create an issue from an epic: + +1. On the epic's page, under **Epics and Issues**, click the arrow next to **Add an issue** and select **Create new issue**. +1. Under **Title**, enter the title for the new issue. +1. From the **Project** dropdown, select the project in which the issue should be created. +1. Click **Create issue**. + To remove an issue from an epic: 1. Click on the <kbd>x</kbd> button in the epic's issue list. @@ -218,7 +236,9 @@ link in the issue sidebar. If you have [permissions](../../permissions.md) to close an issue and create an epic in the parent group, you can promote an issue to an epic with the `/promote` [quick action](../../project/quick_actions.md#quick-actions-for-issues-merge-requests-and-epics). -Only issues from projects that are in groups can be promoted. +Only issues from projects that are in groups can be promoted. When attempting to promote a confidential +issue, a warning will display. Promoting a confidential issue to an epic will make all information +related to the issue public as epics are public to group members. When the quick action is executed: diff --git a/doc/user/group/img/select_group_dropdown.png b/doc/user/group/img/select_group_dropdown.png index 8c03ffffbdee0269e0e11db4ac67b16330d98499..4948cefb65fb40d6d28398fe97b6cc77ac942c43 100644 Binary files a/doc/user/group/img/select_group_dropdown.png and b/doc/user/group/img/select_group_dropdown.png differ diff --git a/doc/user/group/index.md b/doc/user/group/index.md index ad16aaa34ff62f91d7541b9ea649e32bb680edaf..bf0a8c6bfbde438f856ad7f3ec3bc7c01396857a 100644 --- a/doc/user/group/index.md +++ b/doc/user/group/index.md @@ -204,6 +204,25 @@ and give all group members access to the project at once. Alternatively, you can [lock the sharing with group feature](#share-with-group-lock). +## Sharing a group with another group + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/18328) in GitLab 12.7. + +Similarly to [sharing a project with a group](#sharing-a-project-with-a-group), +you can share a group with another group to give direct group members access +to the shared group. This is not valid for inherited members. + +To share a given group, for example, 'Frontend' with another group, for example, +'Engineering': + +1. Navigate to your 'Frontend' group page and use the left navigation menu to go + to your group **Members**. +1. Select the **Invite group** tab. +1. Add 'Engineering' with the maximum access level of your choice. +1. Click **Invite**. + +All the members of the 'Engineering' group will have been added to 'Frontend'. + ## Manage group memberships via LDAP In GitLab Enterprise Edition, it is possible to manage GitLab group memberships using LDAP groups. diff --git a/doc/user/group/subgroups/img/group_members_filter_v12_6.png b/doc/user/group/subgroups/img/group_members_filter_v12_6.png index 0207515ded0f4a77a43e11ab35da4c0949d7fef7..692fdfe00a1cf0e0a9814d0748557e5659ce05d2 100644 Binary files a/doc/user/group/subgroups/img/group_members_filter_v12_6.png and b/doc/user/group/subgroups/img/group_members_filter_v12_6.png differ diff --git a/doc/user/incident_management/img/incident_management_settings.png b/doc/user/incident_management/img/incident_management_settings.png index 25ad4fd08b7544cb0a6e63d7ca667d726bdb0136..761a782ce6137199e0a58128cf90942648a5639a 100644 Binary files a/doc/user/incident_management/img/incident_management_settings.png and b/doc/user/incident_management/img/incident_management_settings.png differ diff --git a/doc/user/instance/clusters/index.md b/doc/user/instance/clusters/index.md index 3d9a1eb219e81655a04c7d2e2d39bf1f8e7ad702..7b5ba14a5aeae6094d4a1860b062fcab6a27dbe0 100644 --- a/doc/user/instance/clusters/index.md +++ b/doc/user/instance/clusters/index.md @@ -12,11 +12,12 @@ projects. ## Cluster precedence -GitLab will try match to clusters in the following order: +GitLab will try [to match](../../../ci/environments.md#scoping-environments-with-specs) clusters in +the following order: -- Project-level clusters -- Group-level clusters -- Instance level +- Project-level clusters. +- Group-level clusters. +- Instance-level clusters. To be selected, the cluster must be enabled and match the [environment selector](../../../ci/environments.md#scoping-environments-with-specs). @@ -26,3 +27,8 @@ match the [environment selector](../../../ci/environments.md#scoping-environment For a consolidated view of which CI [environments](../../../ci/environments.md) are deployed to the Kubernetes cluster, see the documentation for [cluster environments](../../clusters/environments.md). + +## More information + +For information on integrating GitLab and Kubernetes, see +[Kubernetes clusters](../../project/clusters/index.md). diff --git a/doc/user/instance_statistics/dev_ops_score.md b/doc/user/instance_statistics/dev_ops_score.md index fbe4cc3c6df1f758f7ad28c5a512c8f92a16a1cb..68c5bb48c3c9d0dd298b8f32338c218233b52faf 100644 --- a/doc/user/instance_statistics/dev_ops_score.md +++ b/doc/user/instance_statistics/dev_ops_score.md @@ -16,7 +16,7 @@ of top-performing instances based on [usage ping data](../admin_area/settings/us collected. Your score is compared to the lead score of each feature and then expressed as a percentage at the bottom of said feature. Your overall index score is an average of all your feature score percentages - this percentage value is presented above all the of features on the page. - + The page also provides helpful links to articles and GitLab docs, to help you improve your scores. diff --git a/doc/user/instance_statistics/img/cohorts.png b/doc/user/instance_statistics/img/cohorts.png index 4d070fdb654b78bd660eb3a4fbd92cbbf0336ab0..19250e385aa819e439f57e349a611ceab40bc9ab 100644 Binary files a/doc/user/instance_statistics/img/cohorts.png and b/doc/user/instance_statistics/img/cohorts.png differ diff --git a/doc/user/instance_statistics/img/dev_ops_score.png b/doc/user/instance_statistics/img/dev_ops_score.png deleted file mode 100644 index bee1317438d7ea6008ed4708ad7a3b3a6389d01c..0000000000000000000000000000000000000000 Binary files a/doc/user/instance_statistics/img/dev_ops_score.png and /dev/null differ diff --git a/doc/user/instance_statistics/img/dev_ops_score_v12_6.png b/doc/user/instance_statistics/img/dev_ops_score_v12_6.png new file mode 100644 index 0000000000000000000000000000000000000000..af07e9323d63356c401692c975ba69876ecdd1f2 Binary files /dev/null and b/doc/user/instance_statistics/img/dev_ops_score_v12_6.png differ diff --git a/doc/user/instance_statistics/img/instance_statistics_button.png b/doc/user/instance_statistics/img/instance_statistics_button.png deleted file mode 100644 index 6104321b1a6677ad69139004b791037f0b4ad8b4..0000000000000000000000000000000000000000 Binary files a/doc/user/instance_statistics/img/instance_statistics_button.png and /dev/null differ diff --git a/doc/user/instance_statistics/img/instance_statistics_button_v12_6.png b/doc/user/instance_statistics/img/instance_statistics_button_v12_6.png new file mode 100644 index 0000000000000000000000000000000000000000..e5f033141caad1f80fbb7f3af0e421377405effa Binary files /dev/null and b/doc/user/instance_statistics/img/instance_statistics_button_v12_6.png differ diff --git a/doc/user/instance_statistics/index.md b/doc/user/instance_statistics/index.md index 53bf85b6e13662c94a6480ca20d7cf77d796baa5..c31da8d86f1f1630289e6df98880457efc92ff6e 100644 --- a/doc/user/instance_statistics/index.md +++ b/doc/user/instance_statistics/index.md @@ -5,10 +5,10 @@ in GitLab 11.2. Instance statistics gives users or admins access to instance-wide analytics. They are accessible to all users by default (GitLab admins can restrict its -visibility in the [admin area](../admin_area/settings/usage_statistics.md)), +visibility in the [Admin Area](../admin_area/settings/usage_statistics.md)), and can be accessed via the top bar. - + There are two kinds of statistics: diff --git a/doc/user/markdown.md b/doc/user/markdown.md index fdf6cb3c7be41034651bf8d49cae8df1ab83c908..913a4332b1de1f8b4e7e3e612dca8e176bcfbb71 100644 --- a/doc/user/markdown.md +++ b/doc/user/markdown.md @@ -160,7 +160,7 @@ It is possible to generate diagrams and flowcharts from text in GitLab using [Me > [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/15107) in GitLab 10.3. -Visit the [official page](https://mermaidjs.github.io/) for more details. +Visit the [official page](https://mermaidjs.github.io/) for more details. If you are new to using Mermaid or need help identifying issues in your Mermaid code, the [Mermaid Live Editor](https://mermaid-js.github.io/mermaid-live-editor/) is a helpful tool for creating and resolving issues within Mermaid diagrams. In order to generate a diagram or flowchart, you should write your text inside the `mermaid` block: @@ -275,7 +275,7 @@ In GitLab, front matter is only used in Markdown files and wiki pages, not the o places where Markdown formatting is supported. It must be at the very top of the document, and must be between delimiters, as explained below. -The following delimeters are supported: +The following delimiters are supported: - YAML (`---`): @@ -407,6 +407,7 @@ GFM will recognize the following: | merge request | `!123` | `namespace/project!123` | `project!123` | | snippet | `$123` | `namespace/project$123` | `project$123` | | epic **(ULTIMATE)** | `&123` | `group1/subgroup&123` | | +| design **(PREMIUM)** | `#123[file.jpg]` or `#123["file.png"]` | `group1/subgroup#123[file.png]` | `project#123[file.png]` | | label by ID | `~123` | `namespace/project~123` | `project~123` | | one-word label by name | `~bug` | `namespace/project~bug` | `project~bug` | | multi-word label by name | `~"feature request"` | `namespace/project~"feature request"` | `project~"feature request"` | @@ -601,7 +602,7 @@ Inline `code` has `back-ticks around` it. --- Similarly, a whole block of code can be fenced with triple backticks ```` ``` ````, -triple tildes (`~~~`), or indended 4 or more spaces to achieve a similar effect for +triple tildes (`~~~`), or indented 4 or more spaces to achieve a similar effect for a larger body of code. ~~~ @@ -773,17 +774,33 @@ do*this*and*do*that*and*another thing ### Footnotes -Footnotes add a link to a note rendered at the end of a Markdown file: +Footnotes add a link to a note that will be rendered at the end of a Markdown file. + +To make a footnote, you need both a reference tag and a separate line (anywhere in the file) with the note content. + +Regardless of the tag names, the relative order of the reference tags determines the rendered numbering. ```markdown -You can add footnotes to your text as follows.[^1] +A footnote reference tag looks like this:[^1] + +[^1]: This is the contents of a footnote. -[^1]: This is my awesome footnote (later in file). +Reference tags can use letters and other characters.[^footnote-note] + +[^footnote-note]: Avoid using lowercase `w` or an underscore (`_`) +in your footnote tag name until an +[upstream bug](https://gitlab.com/gitlab-org/gitlab/issues/24423) is resolved. ``` -You can add footnotes to your text as follows.[^1] +A footnote reference tag looks like this:[^1] + +[^1]: This is the contents of a footnote. + +Reference tags can use letters and other characters.[^footnote-note] -[^1]: This is my awesome footnote (later in file). +[^footnote-note]: Avoid using lowercase `w` or an underscore (`_`) +in your footnote tag name until an +[upstream bug](https://gitlab.com/gitlab-org/gitlab/issues/24423) is resolved. ### Headers diff --git a/doc/user/packages/conan_repository/img/conan_package_view.png b/doc/user/packages/conan_repository/img/conan_package_view.png index 79a188d7856913ce705955039f668f95e6c97236..742fb4195da3daae587f4561c4f49639bb4f8b32 100644 Binary files a/doc/user/packages/conan_repository/img/conan_package_view.png and b/doc/user/packages/conan_repository/img/conan_package_view.png differ diff --git a/doc/user/packages/conan_repository/index.md b/doc/user/packages/conan_repository/index.md index 2366d1ccc0de0231178dcd2436351cf7c36dce80..bcf2fea24e3adfff285adc9925e8f67dc53d20af 100644 --- a/doc/user/packages/conan_repository/index.md +++ b/doc/user/packages/conan_repository/index.md @@ -22,46 +22,134 @@ by default. To enable it for existing projects, or if you want to disable it: You should then be able to see the **Packages** section on the left sidebar. -Before proceeding to authenticating with the GitLab Conan Repository, you should -get familiar with the package naming convention. +## Getting started -## Authenticating to the GitLab Conan Repository +This section will cover installing Conan and building a package for your C/C++ project. This is a quickstart if you are new +to Conan. If you already are using Conan and understand how to build your own packages, move on to the [next section](#adding-the-gitlab-package-registry-as-a-conan-remote). -You will need to generate a [personal access token](../../../user/profile/personal_access_tokens.md) with the scope set to `api` for repository authentication. +### Installing Conan + +Follow the instructions at [conan.io](https://conan.io/downloads.html) to download the Conan package manager to your local development environment. + +Once installation is complete, verify you can use Conan in your terminal by running + +```sh +conan --version +``` + +You should see the Conan version printed in the output: + +``` +Conan version 1.20.5 +``` + +### Installing CMake -Now you can run conan commands using your token. +When developing with C++ and Conan, you have a wide range of options for compilers. This tutorial walks through using the cmake +compiler. In your terminal, run the command -`CONAN_LOGIN_USERNAME=<gitlab-username> CONAN_PASSWORD=<personal_access_token> conan upload Hello/0.2@user/channel --remote=gitlab` -`CONAN_LOGIN_USERNAME=<gitlab-username> CONAN_PASSWORD=<personal_access_token> conan search Hello* --all --remote=gitlab` +```sh +cmake --version +``` + +You should see the cmake version printed in the output. If you see something else, you may have to install cmake. + +On a Mac, you can use [homebrew](https://brew.sh/) to install cmake by running `brew install cmake`. Otherwise, follow +instructions at [cmake.org](https://cmake.org/install/) for your operating system. + +### Creating a project + +Understanding what is needed to create a valid and compilable C++ project is out of the scope of this guide, but if you are new to C++ and want to try out the GitLab +package registry, Conan.io has a great [hello world starter project](https://github.com/conan-io/hello) that you can clone to get started. -Alternatively, you can set the `CONAN_LOGIN_USERNAME` and `CONAN_PASSWORD` in your local conan config to be used when connecting to the `gitlab` remote. The examples here show the username and password inline. +Clone the repo and it can be used for the rest of the tutorial if you don't have your own C++ project. -Next, you'll need to set your Conan remote to point to the GitLab Package Registry. +### Building a package -## Setting the Conan remote to the GitLab Package Registry +In your terminal, navigate to the root folder of your project. Generate a new recipe by running `conan new` and providing it with a +package name and version: -After you authenticate to the [GitLab Conan Repository](#authenticating-to-the-gitlab-conan-repository), -you can set the Conan remote: +```sh +conan new Hello/0.1 -t +``` + +Next, you will create a package for that recipe by running `conan create` providing the Conan user and channel: + +```sh +conan create . my-org+my-group+my-project/beta +``` + +NOTE: **Note** +Current [naming restrictions](#package-recipe-naming-convention) require you to name the `user` value as the `+` separated path of your project on GitLab. + +The example above would create a package belonging to this project: `https://gitlab.com/my-org/my-group/my-project` with a channel of `beta`. + +These two example commands will generate a final package with the recipe `Hello/0.1@my-org+my-group+my-project/beta`. + +For more advanced details on creating and managing your packages, refer to the [Conan docs](https://docs.conan.io/en/latest/creating_packages.html). + +You are now ready to upload your package to the GitLab registry. To get started, first you will need to set GitLab as a remote, then you will need to add a Conan user for that remote to authenticate your requests. + +## Adding the GitLab Package Registry as a Conan remote + +Add a new remote to your Conan configuration: ```sh conan remote add gitlab https://gitlab.example.com/api/v4/packages/conan ``` -Once the remote is set, you can use the remote when running Conan commands: +Once the remote is set, you can use the remote when running Conan commands by adding `--remote=gitlab` to the end of your commands. + +For example: ```sh conan search Hello* --all --remote=gitlab ``` -## Supported CLI commands +## Authenticating to the GitLab Conan Repository -The GitLab Conan repository supports the following Conan CLI commands: +You will need to generate a [personal access token](../../../user/profile/personal_access_tokens.md) with the scope set to `api` for repository authentication. -- `conan upload`: Upload your recipe and package files to the GitLab Package Registry. -- `conan install`: Install a conan package from the GitLab Package Registry, this includes using the `conan.txt` file. -- `conan search`: Search the GitLab Package Registry for public packages, and private packages you have permission to view. -- `conan info`: View the info on a given package from the GitLab Package Registry. -- `conan remove`: Delete the package from the GitLab Package Registry. +### Adding a Conan user to the GitLab remote + +Once you have a personal access token and have [set your Conan remote](#adding-the-gitlab-package-registry-as-a-conan-remote), you can associate the token with the remote so you do not have to explicitly add them to each Conan command you run: + +```sh +conan user <gitlab-username> -r gitlab -p <personal_access_token> +``` + +Note: **Note** +If you named your remote something other than `gitlab`, your remote name should be used in this command instead of `gitlab`. + +From now on, when you run commands using `--remote=gitlab`, your username and password will automatically be included in the requests. + +Note: **Note** +The personal access token is not stored locally at any moment. Conan uses JWT, so when you run this command, Conan will request an expirable token from GitLab using your token. The JWT does expire on a regular basis, so you will need to re-enter your personal access token when that happens. + +Alternatively, you could explicitly include your credentials in any given command. +For example: + +```sh +CONAN_LOGIN_USERNAME=<gitlab-username> CONAN_PASSWORD=<personal_access_token> conan upload Hello/0.1@my-group+my-project/beta --all --remote=gitlab +``` + +### Setting a default remote to your project (optional) + +If you'd like Conan to always use GitLab as the registry for your package, you can tell Conan to always reference the GitLab remote for a given package recipe: + +```sh +conan remote add_ref Hello/0.1@my-group+my-project/beta gitlab +``` + +NOTE: **Note** +The package recipe does include the version, so setting the default remote for `Hello/0.1@user/channel` will not work for `Hello/0.2@user/channel`. +This functionality is best suited for when you want to consume or install packages from the GitLab registry without having to specify a remote. + +The rest of the example commands in this documentation assume that you have added a Conan user with your credentials to the `gitlab` remote and will not include the explicit credentials or remote option, but be aware that any of the commands could be run without having added a user or default remote: + +```sh +`CONAN_LOGIN_USERNAME=<gitlab-username> CONAN_PASSWORD=<personal_access_token> <conan command> --remote=gitlab +``` ## Uploading a package @@ -72,14 +160,14 @@ Ensure you have a project created on GitLab and that the personal access token y You can upload your package to the GitLab Package Registry using the `conan upload` command: ```sh -CONAN_LOGIN_USERNAME=<gitlab-username> CONAN_PASSWORD=<personal_access_token> conan upload Hello/0.1@my-group+my-project/beta --all --remote=gitlab +conan upload Hello/0.1@my-group+my-project/beta --all ``` ### Package recipe naming convention -Standard Conan recipe convention looks like `package_name/version@username/channel`. +Standard Conan recipe convention looks like `package_name/version@user/channel`. -**Recipe usernames must be the `+` separated project path**. The package +**The recipe user must be the `+` separated project path**. The package name may be anything, but it is preferred that the project name be used unless it is not possible due to a naming collision. For example: @@ -95,7 +183,36 @@ A future iteration will extend support to [project and group level](https://gitl ## Installing a package -Add the conan package to the `[requires]` section of your `conan.txt` file and they will be installed when you run `conan install` within your project. +Conan packages are commonly installed as dependencies using the `conanfile.txt` file. + +In your project where you would like to install the Conan package as a dependency, open `conanfile.txt` or create +an empty file named `conanfile.txt` in the root of your project. + +Add the Conan recipe to the `[requires]` section of the file: + +```ini + [requires] + Hello/0.1@my-group+my-project/beta + + [generators] + cmake +``` + +Next, from the root of your project, create a build directory and navigate to it: + +```sh +mkdir build && cd build +``` + +Now you can install the dependencies listed in `conanfile.txt`: + +```sh +conan install .. +``` + +NOTE: **Note:** +If you are trying to install the package you just created in this tutorial, not much will happen since that package +already exists on your local machine. ## Removing a package @@ -104,9 +221,12 @@ There are two ways to remove a Conan package from the GitLab Package Registry. - **Using the Conan client in the command line:** ```sh - CONAN_LOGIN_USERNAME=<gitlab-username> CONAN_PASSWORD=<personal_access_token> conan remove Hello/0.2@user/channel -r gitlab + conan remove Hello/0.2@user/channel --remote=gitlab ``` + You need to explicitly include the remote in this command, otherwise the package will only be removed from your + local system cache. + NOTE: **Note:** This command will remove all recipe and binary package files from the Package Registry. @@ -119,9 +239,9 @@ The `conan search` command can be run searching by full or partial package name, To search using a partial name, use the wildcard symbol `*`, which should be placed at the end of your search (e.g., `my-packa*`): ```sh -CONAN_LOGIN_USERNAME=<gitlab-username> CONAN_PASSWORD=<personal_access_token> conan search Hello --all --remote=gitlab -CONAN_LOGIN_USERNAME=<gitlab-username> CONAN_PASSWORD=<personal_access_token> conan search He* --all --remote=gitlab -CONAN_LOGIN_USERNAME=<gitlab-username> CONAN_PASSWORD=<personal_access_token> conan search Hello/1.0.0@my-group+my-project/stable --all --remote=gitlab +conan search Hello --all --remote=gitlab +conan search He* --all --remote=gitlab +conan search Hello/0.1@my-group+my-project/beta --all --remote=gitlab ``` The scope of your search will include all projects you have permission to access, this includes your private projects as well as all public projects. @@ -131,5 +251,37 @@ The scope of your search will include all projects you have permission to access The `conan info` command will return info about a given package: ```sh -CONAN_LOGIN_USERNAME=<gitlab-username> CONAN_PASSWORD=<personal_access_token> conan info Hello/1.0.0@my-group+my-project/stable -r gitlab +conan info Hello/0.1@my-group+my-project/beta ``` + +## List of supported CLI commands + +The GitLab Conan repository supports the following Conan CLI commands: + +- `conan upload`: Upload your recipe and package files to the GitLab Package Registry. +- `conan install`: Install a conan package from the GitLab Package Registry, this includes using the `conanfile.txt` file. +- `conan search`: Search the GitLab Package Registry for public packages, and private packages you have permission to view. +- `conan info`: View the info on a given package from the GitLab Package Registry. +- `conan remove`: Delete the package from the GitLab Package Registry. + +## Using GitLab CI with Conan packages + +To work with Conan commands within [GitLab CI](./../../../ci/README.md), you can use +`CI_JOB_TOKEN` in place of the personal access token in your commands. + +It is easiest to provide the `CONAN_LOGIN_USERNAME` and `CONAN_PASSWORD` with each +Conan command in your `.gitlab-ci.yml` file: + +```yml +image: conanio/gcc7 + +create_package: + stage: deploy + script: + - conan remote add gitlab https://gitlab.example.com/api/v4/packages/conan + - conan create . my-group+my-project/beta + - CONAN_LOGIN_USERNAME=ci_user CONAN_PASSWORD=${CI_JOB_TOKEN} conan upload Hello/0.1@root+ci-conan/beta1 --all --remote=gitlab +``` + +You can find additional Conan images to use as the base of your CI file +in the [Conan docs](https://docs.conan.io/en/latest/howtos/run_conan_in_docker.html#available-docker-images). diff --git a/doc/user/packages/container_registry/index.md b/doc/user/packages/container_registry/index.md index 9c1a9d5a41a6105240c8b6d61716493b6a612360..877379705de97a422535732207e31bfa18e62627 100644 --- a/doc/user/packages/container_registry/index.md +++ b/doc/user/packages/container_registry/index.md @@ -20,6 +20,9 @@ You can read more about Docker Registry at <https://docs.docker.com/registry/int ## Enable the Container Registry for your project +CAUTION: **Warning:** +The Container Registry follows the visibility settings of the project. If the project is public, so is the Container Registry. + If you cannot find the **Packages > Container Registry** entry under your project's sidebar, it is not enabled in your GitLab instance. Ask your administrator to enable GitLab Container Registry following the diff --git a/doc/user/packages/dependency_proxy/img/group_dependency_proxy.png b/doc/user/packages/dependency_proxy/img/group_dependency_proxy.png index 035aff0b6c47cec54203b77c8f52fef9b7f10261..0b94efdd83e636c75814582dc54f83538e608901 100644 Binary files a/doc/user/packages/dependency_proxy/img/group_dependency_proxy.png and b/doc/user/packages/dependency_proxy/img/group_dependency_proxy.png differ diff --git a/doc/user/packages/dependency_proxy/index.md b/doc/user/packages/dependency_proxy/index.md index 60f4dbc0abba03a2da3c8bb54a67384f2aeb0130..05934212a122070f8fdeb51ac5a48dcf762d4b00 100644 --- a/doc/user/packages/dependency_proxy/index.md +++ b/doc/user/packages/dependency_proxy/index.md @@ -12,7 +12,7 @@ receiving a request and returning the upstream image from a registry, acting as a pull-through cache. The dependency proxy is available in the group level. To access it, navigate to -a group's **Overview > Dependency Proxy**. +a group's **Packages > Dependency Proxy**.  @@ -33,7 +33,7 @@ The following dependency proxies are supported. With the Docker dependency proxy, you can use GitLab as a source for a Docker image. To get a Docker image into the dependency proxy: -1. Find the proxy URL on your group's page under **Overview > Dependency Proxy**, +1. Find the proxy URL on your group's page under **Packages > Dependency Proxy**, for example `gitlab.com/groupname/dependency_proxy/containers`. 1. Trigger GitLab to pull the Docker image you want (e.g., `alpine:latest` or `linuxserver/nextcloud:latest`) and store it in the proxy storage by using diff --git a/doc/user/packages/index.md b/doc/user/packages/index.md index ecaad960340123f599fe9b224ec4e9d38e935687..c58effac59d04bc44a343a802068d1fc615bc511 100644 --- a/doc/user/packages/index.md +++ b/doc/user/packages/index.md @@ -10,15 +10,21 @@ The Packages feature allows GitLab to act as a repository for the following: | ------------------- | ----------- | --------------------------- | | [Container Registry](container_registry/index.md) | The GitLab Container Registry enables every project in GitLab to have its own space to store [Docker](https://www.docker.com/) images. | 8.8+ | | [Dependency Proxy](dependency_proxy/index.md) **(PREMIUM)** | The GitLab Dependency Proxy sets up a local proxy for frequently used upstream images/packages. | 11.11+ | -| [Conan Repository](conan_repository/index.md) **(PREMIUM)** | The GitLab Conan Repository enables every project in GitLab to have its own space to store [Conan](https://conan.io/) packages. | 12.4+ | +| [Conan Repository](conan_repository/index.md) **(PREMIUM)** | The GitLab Conan Repository enables every project in GitLab to have its own space to store [Conan](https://conan.io/) packages. | 12.6+ | | [Maven Repository](maven_repository/index.md) **(PREMIUM)** | The GitLab Maven Repository enables every project in GitLab to have its own space to store [Maven](https://maven.apache.org/) packages. | 11.3+ | | [NPM Registry](npm_registry/index.md) **(PREMIUM)** | The GitLab NPM Registry enables every project in GitLab to have its own space to store [NPM](https://www.npmjs.com/) packages. | 11.7+ | -| [NuGet Repository](https://gitlab.com/gitlab-org/gitlab/issues/20050) **(PREMIUM)** | *COMING SOON* The GitLab NuGet Repository will enable every project in GitLab to have its own space to store [NuGet](https://www.nuget.org/) packages. | 12.7 (planned) | +| [NuGet Repository](nuget_repository/index.md) **(PREMIUM)** | *PLANNED* The GitLab NuGet Repository will enable every project in GitLab to have its own space to store [NuGet](https://www.nuget.org/) packages. | 12.8+ | TIP: **Tip:** Don't you see your package management system supported yet? Consider contributing to GitLab. This [development documentation](../../development/packages.md) will -guide you through the process. Or check out how other members of the commmunity +guide you through the process. Or check out how other members of the community are adding support for [PHP](https://gitlab.com/gitlab-org/gitlab/merge_requests/17417) or [Terraform](https://gitlab.com/gitlab-org/gitlab/merge_requests/18834). NOTE: **Note** We are especially interested in adding support for [PyPi](https://gitlab.com/gitlab-org/gitlab/issues/10483), [RubyGems](https://gitlab.com/gitlab-org/gitlab/issues/803), [Debian](https://gitlab.com/gitlab-org/gitlab/issues/5835), and [RPM](https://gitlab.com/gitlab-org/gitlab/issues/5932). + +## Package workflows + +Learning how to use the GitLab Package Registry will help you build your own custom package workflow. + +[Use a project as a package registry](./workflows/project_registry.md) to publish all of your packages to one project. diff --git a/doc/user/packages/npm_registry/index.md b/doc/user/packages/npm_registry/index.md index 7d5db5a60ef1b53a3668129d18b6c47f7ae1a987..5fdbbcedfc90713de52892acd27fbb3afbba5577 100644 --- a/doc/user/packages/npm_registry/index.md +++ b/doc/user/packages/npm_registry/index.md @@ -73,20 +73,20 @@ If you have 2FA enabled, you need to use a [personal access token](../../profile ### Authenticating with an OAuth token To authenticate with an [OAuth token](../../../api/oauth2.md#resource-owner-password-credentials-flow) -or [personal access token](../../profile/personal_access_tokens.md), add a corresponding section to your `.npmrc` file: +or [personal access token](../../profile/personal_access_tokens.md), set your NPM configuration: -```ini -; Set URL for your scoped packages. -; For example package with name `@foo/bar` will use this URL for download -@foo:registry=https://gitlab.com/api/v4/packages/npm/ +```bash +# Set URL for your scoped packages. +# For example package with name `@foo/bar` will use this URL for download +npm config set @foo:registry https://gitlab.com/api/v4/packages/npm/ -; Add the token for the scoped packages URL. This will allow you to download -; `@foo/` packages from private projects. -//gitlab.com/api/v4/packages/npm/:_authToken=<your_token> +# Add the token for the scoped packages URL. This will allow you to download +# `@foo/` packages from private projects. +npm config set '//gitlab.com/api/v4/projects/<your_project_id>/packages/npm/:_authToken' "<your_token>" -; Add token for uploading to the registry. Replace <your_project_id> -; with the project you want your package to be uploaded to. -//gitlab.com/api/v4/projects/<your_project_id>/packages/npm/:_authToken=<your_token> +# Add token for uploading to the registry. Replace <your_project_id> +# with the project you want your package to be uploaded to. +npm config set '//gitlab.com/api/v4/packages/npm/:_authToken' "<your_token>" ``` Replace `<your_project_id>` with your project ID which can be found on the home page @@ -103,13 +103,11 @@ If you encounter an error message with [Yarn](https://yarnpkg.com/en/), see the ### Using variables to avoid hard-coding auth token values -To avoid hard-coding the `authToken` value, you may use a variables in its place. -In your `.npmrc` file, you would add: +To avoid hard-coding the `authToken` value, you may use a variables in its place: -```ini -@foo:registry=https://gitlab.com/api/v4/packages/npm/ -//gitlab.com/api/v4/packages/npm/:_authToken=${NPM_TOKEN} -//gitlab.com/api/v4/projects/<your_project_id>/packages/npm/:_authToken=${NPM_TOKEN} +```bash +npm config set '//gitlab.com/api/v4/projects/<your_project_id>/packages/npm/:_authToken' "${NPM_TOKEN}" +npm config set '//gitlab.com/api/v4/packages/npm/:_authToken' "${NPM_TOKEN}" ``` Then, you could run `npm publish` either locally or via GitLab CI/CD: @@ -134,8 +132,8 @@ Add a corresponding section to your `.npmrc` file: ```ini @foo:registry=https://gitlab.com/api/v4/packages/npm/ -//gitlab.com/api/v4/packages/npm/:_authToken=${env.CI_JOB_TOKEN} -//gitlab.com/api/v4/projects/{env.CI_PROJECT_ID>/packages/npm/:_authToken=${env.CI_JOB_TOKEN} +//gitlab.com/api/v4/packages/npm/:_authToken=${CI_JOB_TOKEN} +//gitlab.com/api/v4/projects/${CI_PROJECT_ID}/packages/npm/:_authToken=${CI_JOB_TOKEN} ``` ## Uploading packages @@ -195,7 +193,7 @@ info Visit https://yarnpkg.com/en/docs/cli/install for documentation about this ``` In this case, try adding this to your `.npmrc` file (and replace `<your_oauth_token>` -with your with your OAuth or personal access token): +with your OAuth or personal access token): ```text //gitlab.com/api/v4/projects/:_authToken=<your_oauth_token> @@ -227,6 +225,14 @@ And the `.npmrc` file should look like: @foo:registry=https://gitlab.com/api/v4/packages/npm/ ``` +### `npm install` returns `Error: Failed to replace env in config: ${NPM_TOKEN}` + +You do not need a token to run `npm install` unless your project is private (the token is only required to publish). If the `.npmrc` file was checked in with a reference to `$NPM_TOKEN`, you can remove it. If you prefer to leave the reference in, you'll need to set a value prior to running `npm install` or set the value using [GitLab environment variables](./../../../ci/variables/README.md): + +```bash +NPM_TOKEN=<your_token> npm install +``` + ## NPM dependencies metadata > [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/11867) in GitLab Premium 12.6. @@ -242,3 +248,27 @@ Starting from GitLab 12.6, new packages published to the GitLab NPM Registry exp - bundleDependencies - peerDependencies - deprecated + +## NPM distribution tags + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/9425) in GitLab Premium 12.7. + +Dist Tags for newly published packages are supported, and they follow NPM's convention where they are optional, and each tag can only be assigned to 1 package at +You can add [distribution tags](https://docs.npmjs.com/cli/dist-tag) for newly +published packages. They follow NPM's convention where they are optional, and +each tag can only be assigned to one package at a time. The latest tag is added +by default when a package is published without a tag. The same goes to installing +a package without specifying the tag or version. + +Examples of the supported `dist-tag` commands and using tags in general: + +```sh +npm publish @scope/package --tag # Publish new package with new tag +npm dist-tag add @scope/package@version my-tag # Add a tag to an existing package +npm dist-tag ls @scope/package # List all tags under the package +npm dist-tag rm @scope/package@version my-tag # Delete a tag from the package +npm install @scope/package@my-tag # Install a specific tag +``` + +CAUTION: **Warning:** +Due to a bug in NPM 6.9.0, deleting dist tags fails. Make sure your NPM version is greater than 6.9.1. diff --git a/doc/user/packages/nuget_repository/img/visual_studio_adding_nuget_source.png b/doc/user/packages/nuget_repository/img/visual_studio_adding_nuget_source.png new file mode 100644 index 0000000000000000000000000000000000000000..94b037ced42360aa6364dc97ad5c3a5b534d629c Binary files /dev/null and b/doc/user/packages/nuget_repository/img/visual_studio_adding_nuget_source.png differ diff --git a/doc/user/packages/nuget_repository/img/visual_studio_nuget_source_added.png b/doc/user/packages/nuget_repository/img/visual_studio_nuget_source_added.png new file mode 100644 index 0000000000000000000000000000000000000000..d2f4791a25af09648dee9cc6edee3e39a47284f1 Binary files /dev/null and b/doc/user/packages/nuget_repository/img/visual_studio_nuget_source_added.png differ diff --git a/doc/user/packages/nuget_repository/index.md b/doc/user/packages/nuget_repository/index.md new file mode 100644 index 0000000000000000000000000000000000000000..212641222f83bc09bea60f0253d548c117ce4b8f --- /dev/null +++ b/doc/user/packages/nuget_repository/index.md @@ -0,0 +1,104 @@ +# GitLab NuGet Repository **(PREMIUM)** + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/20050) in [GitLab Premium](https://about.gitlab.com/pricing/) 12.8. + +CAUTION: **Work in progress** +This feature is in development, sections on uploading and installing packages will be coming soon, please follow along and help us make sure we're building the right solution for you in the [NuGet issue](https://gitlab.com/gitlab-org/gitlab/issues/20050). + +With the GitLab NuGet Repository, every project can have its own space to store NuGet packages. + +The GitLab NuGet Repository works with either [nuget CLI](https://www.nuget.org/) or [Visual Studio](https://visualstudio.microsoft.com/vs/). + +## Setting up your development environment + +You will need [nuget CLI](https://www.nuget.org/) 5.2 or above. Previous versions have not been tested against the GitLab NuGet Repository and might not work. You can install it by visiting the [downloads page](https://www.nuget.org/downloads). + +If you have [Visual Studio](https://visualstudio.microsoft.com/vs/), [nuget CLI](https://www.nuget.org/) is probably already installed. + +You can confirm that [nuget CLI](https://www.nuget.org/) is properly installed with: + +```sh +nuget help +``` + +You should see something similar to: + +``` +NuGet Version: 5.2.0.6090 +usage: NuGet <command> [args] [options] +Type 'NuGet help <command>' for help on a specific command. + +Available commands: + +[output truncated] +``` + +## Enabling the NuGet Repository + +NOTE: **Note:** +This option is available only if your GitLab administrator has +[enabled support for the NuGet Repository](../../../administration/packages/index.md).**(PREMIUM ONLY)** + +After the NuGet Repository is enabled, it will be available for all new projects +by default. To enable it for existing projects, or if you want to disable it: + +1. Navigate to your project's **Settings > General > Permissions**. +1. Find the Packages feature and enable or disable it. +1. Click on **Save changes** for the changes to take effect. + +You should then be able to see the **Packages** section on the left sidebar. + +## Adding the GitLab NuGet Repository as a source to nuget + +You will need the following: + +- Your GitLab username. +- A personal access token. You can generate a [personal access token](../../../user/profile/personal_access_tokens.md) with the scope set to `api` for repository authentication. +- A suitable name for your source. +- Your project ID which can be found on the home page of your project. + +You can now add a new source to nuget either using [nuget CLI](https://www.nuget.org/) or [Visual Studio](https://visualstudio.microsoft.com/vs/). + +### Using nuget CLI + +To add the GitLab NuGet Repository as a source with `nuget`: + +```sh +nuget source Add -Name <source_name> -Source "https://example.gitlab.com/api/v4/projects/<your_project_id>/packages/nuget/index.json" -UserName <gitlab_username> -Password <gitlab_token> +``` + +Replace: + +- `<source_name>` with your desired source name. +- `<your_project_id>` with your project ID. +- `<gitlab-username>` with your GitLab username. +- `<gitlab-token>` with your personal access token. +- `example.gitlab.com` with the URL of the GitLab instance you're using. + +For example: + +```sh +nuget source Add -Name "GitLab" -Source "https//gitlab.example/api/v4/projects/10/packages/nuget/index.json" -UserName carol -Password 12345678asdf +``` + +### Using Visual Studio + +1. Open [Visual Studio](https://visualstudio.microsoft.com/vs/). +1. Open the **FILE > OPTIONS** (Windows) or **Visual Studio > Preferences** (Mac OS). +1. In the **NuGet** section, open **Sources**. You will see a list of all your NuGet sources. +1. Click **Add**. +1. Fill the fields with: + - **Name**: Desired name for the source + - **Location**: `https://gitlab.com/api/v4/projects/<your_project_id>/packages/nuget/index.json` + - Replace `<your_project_id>` with your project ID. + - If you have a self-hosted GitLab installation, replace `gitlab.com` with your domain name. + - **Username**: Your GitLab username + - **Password**: Your personal access token + +  + +1. Click **Save**. + +  + +In case of any warning, please make sure that the **Location**, **Username** and **Password** are correct. diff --git a/doc/user/packages/workflows/project_registry.md b/doc/user/packages/workflows/project_registry.md new file mode 100644 index 0000000000000000000000000000000000000000..d8c1c7c28614bb763abd747de80128e22460999b --- /dev/null +++ b/doc/user/packages/workflows/project_registry.md @@ -0,0 +1,85 @@ +# Project as a package registry + +Using the features of the package registry, it is possible to use one project to store all of your packages. + +This guide mirrors the creation of [this package registry](https://gitlab.com/sabrams/my-package-registry). + +For the video version, see [Single Project Package Registry Demo](https://youtu.be/ui2nNBwN35c). + +## How does this work? + +You might be wondering "how is it possible to upload two packages from different codebases to the same project on GitLab?". + +It is easy to forget that a package on GitLab belongs to a project, but a project does not have to be a code repository. +The code used to build your packages can be stored anywhere - maybe it is another project on GitLab, or maybe a completely +different system altogether. All that matters is that when you configure your remote repositories for those packages, you +point them at the same project on GitLab. + +## Why would I do this? + +There are a few reasons you might want to publish all your packages to one project on GitLab: + +1. You want to publish your packages on GitLab, but to a project that is different from where your code is stored. +1. You would like to group packages together in ways that make sense for your usage (all NPM packages in one project, + all packages being used by a specific department in one project, all private packages in one project, etc.) +1. You want to use one remote for all of your packages when installing them into other projects. +1. You would like to migrate your packages to a single place on GitLab from a third-party package registry and do not + want to worry about setting up separate projects for each package. +1. You want to have your CI pipelines build all of your packages to one project so the individual responsible for +validating packages can manage them all in one place. + +## Example walkthrough + +There is no functionality specific to this feature. All we are doing is taking advantage of functionality available in each +of the package management systems to publish packages of different types to the same place. + +Let's take a look at how you might create a public place to hold all of your public packages. + +### Create a project + +First, create a new project on GitLab. It does not have to have any code or content. Make note of the project ID +displayed on the project overview page, as you will need this later. + +### Create an access token + +All of the package repositories available on the GitLab package registry are accessible using [GitLab personal access +tokens](../../profile/personal_access_tokens.md). + +While using CI, you can alternatively use CI job tokens (`CI_JOB_TOKEN`) to authenticate. + +### Configure your local project for the GitLab registry and upload + +There are many ways to use this feature. You can upload all types of packages to the same project, +split things up based on package type, or package visibility level. + +The purpose of this tutorial is to demonstrate the root idea that one project can hold many unrelated +packages, and to allow you to discover the best way to use this functionality yourself. + +#### NPM + +If you are using NPM, this involves creating an `.npmrc` file and adding the appropriate URL for uploading packages +to your project using your project ID, then adding a section to your `package.json` file with a similar URL. + +Follow +the instructions in the [GitLab NPM Registry documentation](../npm_registry/index.md#authenticating-to-the-gitlab-npm-registry). Once +you do this, you will be able to push your NPM package to your project using `npm publish`, as described in the +[uploading packages](../npm_registry/index.md#uploading-packages) section of the docs. + +#### Maven + +If you are using Maven, this involves updating your `pom.xml` file with distribution sections, including the +appropriate URL for your project, as described in the [GitLab Maven Repository documentation](../maven_repository/index.md#project-level-maven-endpoint). +Then, you need to add a `settings.xml` file and [include your access token](../maven_repository/index.md#authenticating-with-a-personal-access-token). +Now you can [deploy Maven packages](../maven_repository/index.md#uploading-packages) to your project. + +#### Conan + +For Conan, first you need to add GitLab as a Conan registry remote. Follow the instructions in the [GitLab Conan Repository docs](../conan_repository/index.md#adding-the-gitlab-package-registry-as-a-conan-remote) +to do so. Then, create your package using the plus-separated (`+`) project path as your Conan user. For example, +if your project is located at `https://gitlab.com/foo/bar/my-proj`, then you can [create your Conan package](../conan_repository/index.md) +using `conan create . foo+bar+my-proj/channel`, where `channel` is your package channel (`stable`, `beta`, etc.). Once your package +is created, you are ready to [upload your package](../conan_repository/index.md#uploading-a-package) depending on your final package recipe. For example: + +```sh +CONAN_LOGIN_USERNAME=<gitlab-username> CONAN_PASSWORD=<personal_access_token> conan upload MyPackage/1.0.0@foo+bar+my-proj/channel --all --remote=gitlab +``` diff --git a/doc/user/permissions.md b/doc/user/permissions.md index 9cbf4fd61924b94c26faa66d7aee7867455e5edd..985c1babdb507d5cf5d9db3a0cdf77a553bf6b20 100644 --- a/doc/user/permissions.md +++ b/doc/user/permissions.md @@ -66,6 +66,7 @@ The following table depicts the various user permission levels in a project. | View confidential issues | (*2*) | ✓ | ✓ | ✓ | ✓ | | Assign issues | | ✓ | ✓ | ✓ | ✓ | | Label issues | | ✓ | ✓ | ✓ | ✓ | +| Set issue weight | | ✓ | ✓ | ✓ | ✓ | | Lock issue threads | | ✓ | ✓ | ✓ | ✓ | | Manage issue tracker | | ✓ | ✓ | ✓ | ✓ | | Manage related issues **(STARTER)** | | ✓ | ✓ | ✓ | ✓ | @@ -274,14 +275,14 @@ External users still count towards a license seat. An administrator can flag a user as external by either of the following methods: - Either [through the API](../api/users.md#user-modification). -- Or by navigating to the **Admin area > Overview > Users** to create a new user +- Or by navigating to the **Admin Area > Overview > Users** to create a new user or edit an existing one. There, you will find the option to flag the user as external. ### Setting new users to external By default, new users are not set as external users. This behavior can be changed -by an administrator under the **Admin Area > Settings > General > Account and limit** page. +by an administrator on the **Admin Area > Settings > General** page, under **Account and limit**. If you change the default behavior of creating new users as external, you will have the option to narrow it down by defining a set of internal users. diff --git a/doc/user/profile/account/create_accounts.md b/doc/user/profile/account/create_accounts.md index c0a887d077970f06cb0ed573fa4cb8799c63a571..27aa57e7f9947ddfa3cd004168f85562f96da7bf 100644 --- a/doc/user/profile/account/create_accounts.md +++ b/doc/user/profile/account/create_accounts.md @@ -15,7 +15,7 @@ If you have [sign-up enabled](../../admin_area/settings/sign_up_restrictions.md)  -## Create users in admin area +## Create users in Admin Area As an admin user, you can manually create users by: diff --git a/doc/user/profile/account/img/admin_user_button.png b/doc/user/profile/account/img/admin_user_button.png index 6be9c1e266aecaaec511fd7a1ba49472505d7e00..506e16bb8cac5e4ca78f47c6e28c36345a1857b6 100644 Binary files a/doc/user/profile/account/img/admin_user_button.png and b/doc/user/profile/account/img/admin_user_button.png differ diff --git a/doc/user/profile/account/img/admin_user_form.png b/doc/user/profile/account/img/admin_user_form.png index ede96373c7300812d9938f983a2ea4713f7892c7..aebc31ee3ffbf49b5ab914751a4c19623d7bca86 100644 Binary files a/doc/user/profile/account/img/admin_user_form.png and b/doc/user/profile/account/img/admin_user_form.png differ diff --git a/doc/user/profile/account/img/register_tab.png b/doc/user/profile/account/img/register_tab.png index 73faa3edd1c2472cf031e89846cda458e480f51e..4bbb4e626875446cd3fceb2582203b6e11b93adb 100644 Binary files a/doc/user/profile/account/img/register_tab.png and b/doc/user/profile/account/img/register_tab.png differ diff --git a/doc/user/profile/active_sessions.md b/doc/user/profile/active_sessions.md index f68b11a57ec40b1d9090e01d817cd9b98b0f921d..11e5ef293e42ce09c8880f0c2dfd364e2ba413d7 100644 --- a/doc/user/profile/active_sessions.md +++ b/doc/user/profile/active_sessions.md @@ -24,6 +24,11 @@ review the sessions, and revoke any you don't recognize. GitLab allows users to have up to 100 active sessions at once. If the number of active sessions exceeds 100, the oldest ones are deleted. +## Revoking a session + +1. Use the previous steps to navigate to **Active Sessions**. +1. Click on **Revoke** besides a session. The current session cannot be revoked, as this would sign you out of GitLab. + <!-- ## Troubleshooting Include any troubleshooting steps that you can foresee. If you know beforehand what issues diff --git a/doc/user/profile/img/active_sessions_list.png b/doc/user/profile/img/active_sessions_list.png index 41173c7eee5c7618c3cb2822e8acda9d0c8603c8..5d94dca69ccfded5543a6d1fc7413230faab0018 100644 Binary files a/doc/user/profile/img/active_sessions_list.png and b/doc/user/profile/img/active_sessions_list.png differ diff --git a/doc/user/profile/personal_access_tokens.md b/doc/user/profile/personal_access_tokens.md index 980a7d5968d20c865124020b893674921bf20bb6..09825bd5ff801f5a6d36ed8075d21d8948e4a15c 100644 --- a/doc/user/profile/personal_access_tokens.md +++ b/doc/user/profile/personal_access_tokens.md @@ -6,15 +6,13 @@ type: concepts, howto > [Introduced][ce-3749] in GitLab 8.8. -Personal access tokens are the preferred way for third party applications and scripts to -authenticate with the [GitLab API][api], if using [OAuth2](../../api/oauth2.md) is not practical. +If you're unable to use [OAuth2](../../api/oauth2.md), you can use a personal access token to authenticate with the [GitLab API][api]. -You can also use personal access tokens to authenticate against Git over HTTP or SSH. They must be used when you have [Two-Factor Authentication (2FA)][2fa] enabled. Authenticate with a token in place of your password. +You can also use personal access tokens with Git to authenticate over HTTP or SSH. Personal access tokens are required when [Two-Factor Authentication (2FA)][2fa] is enabled. In both cases, you can authenticate with a token in place of your password. -To make [authenticated requests to the API][usage], use either the `private_token` parameter or the `Private-Token` header. +Personal access tokens expire on the date you define, at midnight UTC. -The expiration of personal access tokens happens on the date you define, -at midnight UTC. +For examples of how you can use a personal access token to authenticate with the API, see the following section from our [API Docs](../../api/README.md#personal-access-tokens). ## Creating a personal access token diff --git a/doc/user/project/clusters/add_remove_clusters.md b/doc/user/project/clusters/add_remove_clusters.md index 6a0377f118d2f82dededf4b2ddafc56b26019b1e..a77584c04855e18fa757547f1e87536cdb5a31d9 100644 --- a/doc/user/project/clusters/add_remove_clusters.md +++ b/doc/user/project/clusters/add_remove_clusters.md @@ -10,6 +10,74 @@ Every new Google Cloud Platform (GCP) account receives [$300 in credit upon sign and in partnership with Google, GitLab is able to offer an additional $200 for new GCP accounts to get started with GitLab's Google Kubernetes Engine Integration. All you have to do is [follow this link](https://cloud.google.com/partners/partnercredit/?pcn_code=0014M00001h35gDQAQ#contact-form) and apply for credit. +## Before you begin + +Before [adding a Kubernetes cluster](#add-new-cluster) using GitLab, you need: + +- GitLab itself. Either: + - A GitLab.com [account](https://about.gitlab.com/pricing/#gitlab-com). + - A [self-managed installation](https://about.gitlab.com/pricing/#self-managed) with GitLab version + 12.5 or later. This will ensure the GitLab UI can be used for cluster creation. +- The following GitLab access: + - [Maintainer access to a project](../../permissions.md#project-members-permissions) for a + project-level cluster. + - [Maintainer access to a group](../../permissions.md#group-members-permissions) for a + group-level cluster. + - [Admin Area access](../../admin_area/index.md) for a self-managed instance-level + cluster. **(CORE ONLY)** + +### GKE requirements + +Before creating your first cluster on Google GKE with GitLab's integration, make sure the following +requirements are met: + +- A [billing account](https://cloud.google.com/billing/docs/how-to/manage-billing-account) + set up with access. +- The Kubernetes Engine API and related service are enabled. It should work immediately but may + take up to 10 minutes after you create a project. For more information see the + ["Before you begin" section of the Kubernetes Engine docs](https://cloud.google.com/kubernetes-engine/docs/quickstart#before-you-begin). + +### EKS requirements + +Before creating your first cluster on Amazon EKS with GitLab's integration, make sure the following +requirements are met: + +- An [Amazon Web Services](https://aws.amazon.com/) account is set up and you are able to log in. +- You have permissions to manage IAM resources. +- If you want to use an [existing EKS cluster](#existing-eks-cluster): + - An Amazon EKS cluster with worker nodes properly configured. + - `kubectl` [installed and configured](https://docs.aws.amazon.com/eks/latest/userguide/getting-started.html#get-started-kubectl) + for access to the EKS cluster. + +#### Additional requirements for self-managed instances **(CORE ONLY)** + +If you are using a self-managed GitLab instance, GitLab must first be configured with a set of +Amazon credentials. These credentials will be used to assume an Amazon IAM role provided by the user +creating the cluster. Create an IAM user and ensure it has permissions to assume the role(s) that +your users will use to create EKS clusters. + +For example, the following policy document allows assuming a role whose name starts with +`gitlab-eks-` in account `123456789012`: + +```json +{ + "Version": "2012-10-17", + "Statement": { + "Effect": "Allow", + "Action": "sts:AssumeRole", + "Resource": "arn:aws:iam::123456789012:role/gitlab-eks-*" + } +} +``` + +Generate an access key for the IAM user, and configure GitLab with the credentials: + +1. Navigate to **Admin Area > Settings > Integrations** and expand the **Amazon EKS** section. +1. Check **Enable Amazon EKS integration**. +1. Enter the account ID and access key credentials into the respective + `Account ID`, `Access key ID` and `Secret access key` fields. +1. Click **Save changes**. + ## Access controls When creating a cluster in GitLab, you will be asked if you would like to create either: @@ -116,57 +184,39 @@ New clusters can be added using GitLab for: - Google Kubernetes Engine. - Amazon Elastic Kubernetes Service. -### GKE cluster - -GitLab supports: - -- Creating a new GKE cluster using the GitLab UI. -- Providing credentials to add an [existing Kubernetes cluster](#add-existing-cluster). - -Starting from [GitLab 12.4](https://gitlab.com/gitlab-org/gitlab/issues/25925), all the GKE clusters provisioned by GitLab are [VPC-native](https://cloud.google.com/kubernetes-engine/docs/how-to/alias-ips). +### New GKE cluster -NOTE: **Note:** -The [Google authentication integration](../../../integration/google.md) must -be enabled in GitLab at the instance level. If that's not the case, ask your -GitLab administrator to enable it. On GitLab.com, this is enabled. - -#### GKE Requirements +Starting from [GitLab 12.4](https://gitlab.com/gitlab-org/gitlab/issues/25925), all the GKE clusters +provisioned by GitLab are [VPC-native](https://cloud.google.com/kubernetes-engine/docs/how-to/alias-ips). -Before creating your first cluster on Google Kubernetes Engine with GitLab's -integration, make sure the following requirements are met: - -- A [billing account](https://cloud.google.com/billing/docs/how-to/manage-billing-account) - is set up and you have permissions to access it. -- The Kubernetes Engine API and related service are enabled. It should work immediately but may take up to 10 minutes after you create a project. For more information see the - ["Before you begin" section of the Kubernetes Engine docs](https://cloud.google.com/kubernetes-engine/docs/quickstart#before-you-begin). +#### Important notes -Also note the following: +Note the following: +- The [Google authentication integration](../../../integration/google.md) must be enabled in GitLab + at the instance level. If that's not the case, ask your GitLab administrator to enable it. On + GitLab.com, this is enabled. - Starting from [GitLab 12.1](https://gitlab.com/gitlab-org/gitlab-foss/issues/55902), all GKE clusters created by GitLab are RBAC-enabled. Take a look at the [RBAC section](#rbac-cluster-resources) for more information. - Starting from [GitLab 12.5](https://gitlab.com/gitlab-org/gitlab/merge_requests/18341), the cluster's pod address IP range will be set to /16 instead of the regular /14. /16 is a CIDR notation. - -NOTE: **Note:** -GitLab requires basic authentication enabled and a client certificate issued for the cluster in -order to setup an [initial service account](#access-controls). Starting from [GitLab -11.10](https://gitlab.com/gitlab-org/gitlab-foss/issues/58208), the cluster creation process will -explicitly request that basic authentication and client certificate is enabled. +- GitLab requires basic authentication enabled and a client certificate issued for the cluster to + set up an [initial service account](#access-controls). Starting from [GitLab + 11.10](https://gitlab.com/gitlab-org/gitlab-foss/issues/58208), the cluster creation process will + explicitly request that basic authentication and client certificate is enabled. #### Creating the cluster on GKE -If all of the above requirements are met, you can proceed to create and add a -new Kubernetes cluster to your project: - -1. Navigate to your project's **Operations > Kubernetes** page. - - NOTE: **Note:** - You need Maintainer [permissions](../../permissions.md) and above to access the Kubernetes page. +To create and add a new Kubernetes cluster to your project, group, or instance: +1. Navigate to your: + - Project's **Operations > Kubernetes** page, for a project-level cluster. + - Group's **Kubernetes** page, for a group-level cluster. + - **Admin Area > Kubernetes** page, for an instance-level cluster. 1. Click **Add Kubernetes cluster**. -1. Click **Create with Google Kubernetes Engine**. +1. Under the **Create new cluster** tab, click **Google GKE**. 1. Connect your Google account if you haven't done already by clicking the **Sign in with Google** button. 1. Choose your cluster's settings: @@ -198,64 +248,19 @@ separately after the cluster has been created. This means that Cloud Run (Knative), Istio, and HTTP Load Balancing will be enabled on the cluster at create time and cannot be [installed or uninstalled](../../clusters/applications.md) separately. -### EKS Cluster - -GitLab supports: - -- Creating a new EKS cluster using the GitLab UI - ([Introduced](https://gitlab.com/gitlab-org/gitlab/issues/22392) in GitLab 12.5). -- Providing credentials to add an [existing Kubernetes cluster](#add-existing-cluster). - -#### EKS Requirements - -Before creating your first cluster on Amazon EKS with GitLab's integration, -make sure the following requirements are met: - -- An [Amazon Web Services](https://aws.amazon.com/) account is set up and you are able to log in. -- You have permissions to manage IAM resources. - -##### Additional requirements for self-managed instances - -If you are using a self-managed GitLab instance, GitLab must first -be configured with a set of Amazon credentials. These credentials -will be used to assume an Amazon IAM role provided by the user -creating the cluster. Create an IAM user and ensure it has permissions -to assume the role(s) that your users will use to create EKS clusters. - -For example, the following policy document allows assuming a role whose name starts with -`gitlab-eks-` in account `123456789012`: - -```json -{ - "Version": "2012-10-17", - "Statement": { - "Effect": "Allow", - "Action": "sts:AssumeRole", - "Resource": "arn:aws:iam::123456789012:role/gitlab-eks-*" - } -} -``` - -Generate an access key for the IAM user, and configure GitLab with the credentials: - -1. Navigate to **Admin Area > Settings > Integrations** and expand the **Amazon EKS** section. -1. Check **Enable Amazon EKS integration**. -1. Enter the account ID and access key credentials into the respective - `Account ID`, `Access key ID` and `Secret access key` fields. -1. Click **Save changes**. - -#### Creating the cluster on EKS - -If all of the above requirements are met, you can proceed to create and add a -new Kubernetes cluster to your project: +### New EKS cluster -1. Navigate to your project's **Operations > Kubernetes** page. +> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/22392) in GitLab 12.5. - NOTE: **Note:** - You need Maintainer [permissions](../../permissions.md) and above to access the Kubernetes page. +To create and add a new Kubernetes cluster to your project, group, or instance: +1. Navigate to your: + - Project's **Operations > Kubernetes** page, for a project-level cluster. + - Group's **Kubernetes** page, for a group-level cluster. + - **Admin Area > Kubernetes** page, for an instance-level cluster. 1. Click **Add Kubernetes cluster**. -1. Click **Amazon EKS**. You will be provided with an `Account ID` and `External ID` to use in the next step. +1. Under the **Create new cluster** tab, click **Amazon EKS**. You will be provided with an + `Account ID` and `External ID` to use in the next step. 1. In the [IAM Management Console](https://console.aws.amazon.com/iam/home), create an IAM role: 1. From the left panel, select **Roles**. 1. Click **Create role**. @@ -331,8 +336,9 @@ new Kubernetes cluster to your project: - **Kubernetes cluster name** - The name you wish to give the cluster. - **Environment scope** - The [associated environment](index.md#setting-the-environment-scope-premium) to this cluster. - **Kubernetes version** - The Kubernetes version to use. Currently the only version supported is 1.14. - - **Role name** - Select the [IAM role](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles.html) - to allow Amazon EKS and the Kubernetes control plane to manage AWS resources on your behalf. + - **Role name** - Select the [IAM role](https://docs.aws.amazon.com/eks/latest/userguide/service_IAM_role.html) + to allow Amazon EKS and the Kubernetes control plane to manage AWS resources on your behalf. This IAM role is separate + to the IAM role created above, you will need to create it if it does not yet exist. - **Region** - The [region](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/using-regions-availability-zones.html) in which the cluster will be created. - **Key pair name** - Select the [key pair](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-key-pairs.html) @@ -356,24 +362,23 @@ to install some [pre-defined applications](index.md#installing-applications). If you have either of the following types of clusters already, you can add them to a project: -- [Google Kubernetes Engine cluster](#add-existing-gke-cluster). -- [Amazon Elastic Kubernetes Service](#add-existing-eks-cluster). +- [Google Kubernetes Engine cluster](#existing-gke-cluster). +- [Amazon Elastic Kubernetes Service](#existing-eks-cluster). NOTE: **Note:** Kubernetes integration is not supported for arm64 clusters. See the issue [Helm Tiller fails to install on arm64 cluster](https://gitlab.com/gitlab-org/gitlab-foss/issues/64044) for details. -### Add existing GKE cluster - -To add an existing Kubernetes cluster to your project: - -1. Navigate to your project's **Operations > Kubernetes** page. +### Existing GKE cluster - NOTE: **Note:** - You need Maintainer [permissions](../../permissions.md) and above to access the Kubernetes page. +To add an existing GKE cluster to your project, group, or instance: +1. Navigate to your: + - Project's **Operations > Kubernetes** page, for a project-level cluster. + - Group's **Kubernetes** page, for a group-level cluster. + - **Admin Area > Kubernetes** page, for an instance-level cluster. 1. Click **Add Kubernetes cluster**. -1. Click **Add an existing Kubernetes cluster** and fill in the details: +1. Click the **Add existing cluster** tab and fill in the details: - **Kubernetes cluster name** (required) - The name you wish to give the cluster. - **Environment scope** (required) - The [associated environment](index.md#setting-the-environment-scope-premium) to this cluster. @@ -389,7 +394,7 @@ To add an existing Kubernetes cluster to your project: ``` - **CA certificate** (required) - A valid Kubernetes certificate is needed to authenticate to the cluster. We will use the certificate created by default. - - List the secrets with `kubectl get secrets`, and one should named similar to + - List the secrets with `kubectl get secrets`, and one should be named similar to `default-token-xxxxx`. Copy that token name for use below. - Get the certificate by running this command: @@ -508,136 +513,110 @@ To add an existing Kubernetes cluster to your project: After a couple of minutes, your cluster will be ready to go. You can now proceed to install some [pre-defined applications](index.md#installing-applications). -### Add existing EKS cluster - -In this section, we will show how to integrate an [Amazon EKS](https://aws.amazon.com/eks/) cluster with GitLab and begin -deploying applications. - -#### Requirements - -To integrate with with EKS, you will need: - -- An account on GitLab, like [GitLab.com](https://gitlab.com). -- An Amazon EKS cluster (with worker nodes properly configured). -- `kubectl` [installed and configured for access to the EKS cluster](https://docs.aws.amazon.com/eks/latest/userguide/getting-started.html#get-started-kubectl). - -If you don't have an Amazon EKS cluster, one can be created by following the -[EKS getting started guide](https://docs.aws.amazon.com/eks/latest/userguide/getting-started.html). - -#### Configuring and connecting the EKS cluster - -From the left side bar, hover over **Operations > Kubernetes > Add Kubernetes cluster**, -then click **Add an existing Kubernetes cluster**. - -A few details from the EKS cluster will be required to connect it to GitLab: - -1. **Retrieve the certificate**: A valid Kubernetes certificate is needed to - authenticate to the EKS cluster. We will use the certificate created by default. - Open a shell and use `kubectl` to retrieve it: - - - List the secrets with `kubectl get secrets`, and one should named similar to - `default-token-xxxxx`. Copy that token name for use below. - - Get the certificate with: - - ```sh - kubectl get secret <secret name> -o jsonpath="{['data']['ca\.crt']}" | base64 --decode - ``` - -1. **Create admin token**: A `cluster-admin` token is required to install and - manage Helm Tiller. GitLab establishes mutual SSL auth with Helm Tiller - and creates limited service accounts for each application. To create the - token we will create an admin service account as follows: - - 1. Create a file called `eks-admin-service-account.yaml` with contents: - - ```yaml - apiVersion: v1 - kind: ServiceAccount - metadata: - name: eks-admin - namespace: kube-system - ``` - - 1. Apply the service account to your cluster: - - ```bash - kubectl apply -f eks-admin-service-account.yaml - ``` - - Output: - - ```bash - serviceaccount "eks-admin" created - ``` - - 1. Create a file called `eks-admin-cluster-role-binding.yaml` with contents: - - ```yaml - apiVersion: rbac.authorization.k8s.io/v1beta1 - kind: ClusterRoleBinding - metadata: - name: eks-admin - roleRef: - apiGroup: rbac.authorization.k8s.io - kind: ClusterRole - name: cluster-admin - subjects: - - kind: ServiceAccount - name: eks-admin - namespace: kube-system - ``` - - 1. Apply the cluster role binding to your cluster: - - ```bash - kubectl apply -f eks-admin-cluster-role-binding.yaml - ``` - - Output: - - ```bash - clusterrolebinding "eks-admin" created - ``` - - 1. Retrieve the token for the `eks-admin` service account: - - ```bash - kubectl -n kube-system describe secret $(kubectl -n kube-system get secret | grep eks-admin | awk '{print $1}') - ``` - - Copy the `<authentication_token>` value from the output: - - ```yaml - Name: eks-admin-token-b5zv4 - Namespace: kube-system - Labels: <none> - Annotations: kubernetes.io/service-account.name=eks-admin +### Existing EKS cluster + +To add an existing EKS cluster to your project, group, or instance: + +1. Perform the following steps on the EKS cluster: + 1. Retrieve the certificate. A valid Kubernetes certificate is needed to authenticate to the + EKS cluster. We will use the certificate created by default. + Open a shell and use `kubectl` to retrieve it: + + 1. List the secrets with `kubectl get secrets`, and one should named similar to + `default-token-xxxxx`. Copy that token name for use below. + 1. Get the certificate with: + + ```sh + kubectl get secret <secret name> -o jsonpath="{['data']['ca\.crt']}" | base64 --decode + ``` + + 1. Create admin token. A `cluster-admin` token is required to install and manage Helm Tiller. + GitLab establishes mutual SSL authentication with Helm Tiller and creates limited service + accounts for each application. To create the token we will create an admin service account as + follows: + + 1. Create a file called `eks-admin-service-account.yaml` with contents: + + ```yaml + apiVersion: v1 + kind: ServiceAccount + metadata: + name: eks-admin + namespace: kube-system + ``` + + 1. Apply the service account to your cluster: + + ```shell + $ kubectl apply -f eks-admin-service-account.yaml + serviceaccount "eks-admin" created + ``` + + 1. Create a file called `eks-admin-cluster-role-binding.yaml` with contents: + + ```yaml + apiVersion: rbac.authorization.k8s.io/v1beta1 + kind: ClusterRoleBinding + metadata: + name: eks-admin + roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: cluster-admin + subjects: + - kind: ServiceAccount + name: eks-admin + namespace: kube-system + ``` + + 1. Apply the cluster role binding to your cluster: + + ```shell + $ kubectl apply -f eks-admin-cluster-role-binding.yaml + clusterrolebinding "eks-admin" created + ``` + + 1. Retrieve the token for the `eks-admin` service account: + + ```bash + kubectl -n kube-system describe secret $(kubectl -n kube-system get secret | grep eks-admin | awk '{print $1}') + ``` + + Copy the `<authentication_token>` value from the output: + + ```yaml + Name: eks-admin-token-b5zv4 + Namespace: kube-system + Labels: <none> + Annotations: kubernetes.io/service-account.name=eks-admin kubernetes.io/service-account.uid=bcfe66ac-39be-11e8-97e8-026dce96b6e8 - Type: kubernetes.io/service-account-token - - Data - ==== - ca.crt: 1025 bytes - namespace: 11 bytes - token: <authentication_token> - ``` - -1. The API server endpoint is also required, so GitLab can connect to the cluster. - This is displayed on the AWS EKS console, when viewing the EKS cluster details. - -You now have all the information needed to connect the EKS cluster: - -- Kubernetes cluster name: Provide a name for the cluster to identify it within GitLab. -- Environment scope: Leave this as `*` for now, since we are only connecting a single cluster. -- API URL: Paste in the API server endpoint retrieved above. -- CA Certificate: Paste the certificate data from the earlier step, as-is. -- Paste the admin token value. -- Project namespace: This can be left blank to accept the default namespace, based on the project name. - - + Type: kubernetes.io/service-account-token + + Data + ==== + ca.crt: 1025 bytes + namespace: 11 bytes + token: <authentication_token> + ``` + + 1. Locate the the API server endpoint so GitLab can connect to the cluster. This is displayed on + the AWS EKS console, when viewing the EKS cluster details. +1. Navigate to your: + - Project's **Operations > Kubernetes** page, for a project-level cluster. + - Group's **Kubernetes** page, for a group-level cluster. + - **Admin Area > Kubernetes** page, for an instance-level cluster. +1. Click **Add Kubernetes cluster**. +1. Click the **Add existing cluster** tab and fill in the details: + - **Kubernetes cluster name**: A name for the cluster to identify it within GitLab. + - **Environment scope**: Leave this as `*` for now, since we are only connecting a single cluster. + - **API URL**: The API server endpoint retrieved earlier. + - **CA Certificate**: The certificate data from the earlier step, as-is. + - **Service Token**: The admin token value. + - For project-level clusters, **Project namespace prefix**: This can be left blank to accept the + default namespace, based on the project name. +1. Click on **Add Kubernetes cluster**. The cluster is now connected to GitLab. -Click on **Add Kubernetes cluster**, the cluster is now connected to GitLab. At this point, [Kubernetes deployment variables](index.md#deployment-variables) will automatically be available during CI/CD jobs, making it easy to interact with the cluster. @@ -719,16 +698,19 @@ To disable the Kubernetes cluster integration, follow the same procedure. ## Removing integration -To remove the Kubernetes cluster integration from your project, simply click the -**Remove integration** button. You will then be able to follow the procedure -and add a Kubernetes cluster again. +To remove the Kubernetes cluster integration from your project, either: + +- Select **Remove integration**, to remove only the Kubernetes integration. +- [From GitLab 12.6](https://gitlab.com/gitlab-org/gitlab/issues/26815), select + **Remove integration and resources**, to also remove all related GitLab cluster resources (for + example, namespaces, roles, and bindings) when removing the integration. When removing the cluster integration, note: - You need Maintainer [permissions](../../permissions.md) and above to remove a Kubernetes cluster integration. - When you remove a cluster, you only remove its relationship to GitLab, not the cluster itself. To - remove the cluster, you can do so by visiting the GKE dashboard or using `kubectl`. + remove the cluster, you can do so by visiting the GKE or EKS dashboard, or using `kubectl`. ## Learn more diff --git a/doc/user/project/clusters/eks_and_gitlab/index.md b/doc/user/project/clusters/eks_and_gitlab/index.md index fda8cd6340efee28e24258f7f8313ff5e967575f..9bb8f6cb83c8042b103866215741f56122eed4ca 100644 --- a/doc/user/project/clusters/eks_and_gitlab/index.md +++ b/doc/user/project/clusters/eks_and_gitlab/index.md @@ -1,5 +1,5 @@ --- -redirect_to: '../add_remove_clusters.md#add-existing-eks-cluster' +redirect_to: '../add_remove_clusters.md#existing-eks-cluster' --- -This document was moved to [another location](../add_remove_clusters.md#add-existing-eks-cluster). +This document was moved to [another location](../add_remove_clusters.md#existing-eks-cluster). diff --git a/doc/user/project/clusters/img/add_cluster.png b/doc/user/project/clusters/img/add_cluster.png deleted file mode 100644 index 94ec83f15146828cd2eb78708a78d80b3874f825..0000000000000000000000000000000000000000 Binary files a/doc/user/project/clusters/img/add_cluster.png and /dev/null differ diff --git a/doc/user/project/clusters/index.md b/doc/user/project/clusters/index.md index 6d863a8b88897eb9275bbe210212776507e57a7c..895cc6c4b5745d22176f11fa46067f7b2f369758 100644 --- a/doc/user/project/clusters/index.md +++ b/doc/user/project/clusters/index.md @@ -30,9 +30,6 @@ Using the GitLab project Kubernetes integration, you can: - View [Pod logs](#pod-logs-ultimate). **(ULTIMATE)** - Run serverless workloads on [Kubernetes with Knative](serverless/index.md). -See [Adding and removing Kubernetes clusters](add_remove_clusters.md) for details on how to -set up integrations. - ### Deploy Boards **(PREMIUM)** GitLab's Deploy Boards offer a consolidated view of the current health and @@ -79,10 +76,7 @@ Kubernetes clusters can be used without Auto DevOps. ### Web terminals -NOTE: **Note:** -Introduced in GitLab 8.15. You must be the project owner or have `maintainer` permissions -to use terminals. Support is limited to the first container in the -first pod of your environment. +> Introduced in GitLab 8.15. When enabled, the Kubernetes service adds [web terminal](../../../ci/environments.md#web-terminals) support to your [environments](../../../ci/environments.md). This is based on the `exec` functionality found in @@ -97,6 +91,14 @@ pods are annotated with: `$CI_ENVIRONMENT_SLUG` and `$CI_PROJECT_PATH_SLUG` are the values of the CI variables. +You must be the project owner or have `maintainer` permissions to use terminals. Support is limited +to the first container in the first pod of your environment. + +## Adding and removing clusters + +See [Adding and removing Kubernetes clusters](add_remove_clusters.md) for details on how to +set up integrations with Google Cloud Platform (GCP) and Amazon Elastic Kubernetes Service (EKS). + ## Cluster configuration After [adding a Kubernetes cluster](add_remove_clusters.md) to GitLab, read this section that covers @@ -115,8 +117,8 @@ applications running on the cluster. ### GitLab-managed clusters -> [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/22011) in GitLab 11.5. -> Became [optional](https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/26565) in GitLab 11.11. +> - [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/22011) in GitLab 11.5. +> - Became [optional](https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/26565) in GitLab 11.11. You can choose to allow GitLab to manage your cluster for you. If your cluster is managed by GitLab, resources for your projects will be automatically created. See the diff --git a/doc/user/project/clusters/kubernetes_pod_logs.md b/doc/user/project/clusters/kubernetes_pod_logs.md index 797ddf784cce84cf6b18292a62a33f77c1e99172..7cd5d99ef675a81908fb782dbaa6c668eda9d389 100644 --- a/doc/user/project/clusters/kubernetes_pod_logs.md +++ b/doc/user/project/clusters/kubernetes_pod_logs.md @@ -41,9 +41,37 @@ Logs can be displayed by clicking on a specific pod from [Deploy Boards](../depl 1. On the **Environments** page, you should see the status of the environment's pods with [Deploy Boards](../deploy_boards.md). 1. When mousing over the list of pods, a tooltip will appear with the exact pod name and status.  -1. Click on the desired pod to bring up the logs view, which will contain the last 500 lines for that pod. - You may switch between the following in this view: - - Pods. - - [From GitLab 12.4](https://gitlab.com/gitlab-org/gitlab/issues/5769), environments. +1. Click on the desired pod to bring up the logs view. - Support for pods with multiple containers is coming [in a future release](https://gitlab.com/gitlab-org/gitlab/issues/6502). +### Logs view + +The logs view will contain the last 500 lines for a pod, and has control to filter via: + +- Pods. +- [From GitLab 12.4](https://gitlab.com/gitlab-org/gitlab/issues/5769), environments. +- [From GitLab 12.7](https://gitlab.com/gitlab-org/gitlab/merge_requests/21656), [full text search](#full-text-search). + +Support for pods with multiple containers is coming [in a future release](https://gitlab.com/gitlab-org/gitlab/issues/13404). + +Support for historical data is coming [in a future release](https://gitlab.com/gitlab-org/gitlab/issues/196191). + +### Full text search + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/merge_requests/21656) in GitLab 12.7. + +When you enable [Elastic Stack](../../clusters/applications.md#elastic-stack) on your cluster, +you can search the content of your logs via a search bar. + +The search is passed on to Elasticsearch using the [simple_query_string](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-simple-query-string-query.html) +Elasticsearch function, which supports the following operators: + +``` ++ signifies AND operation +| signifies OR operation +- negates a single token +" wraps a number of tokens to signify a phrase for searching +* at the end of a term signifies a prefix query +( and ) signify precedence +~N after a word signifies edit distance (fuzziness) +~N after a phrase signifies slop amount +``` diff --git a/doc/user/project/clusters/serverless/aws.md b/doc/user/project/clusters/serverless/aws.md index 0b74f1e73eb8b211a341d52fb73c3754b851cc5f..220ce2593bb8ee65834e8db8b69e29b456f158fa 100644 --- a/doc/user/project/clusters/serverless/aws.md +++ b/doc/user/project/clusters/serverless/aws.md @@ -121,7 +121,6 @@ This example code does the following: - Installs the Serverless Framework. - Deploys the serverless function to your AWS account using the AWS credentials defined above. - - Deploys the serverless function to your AWS account using the AWS credentials defined above ### Setting up your AWS credentials with your GitLab account diff --git a/doc/user/project/clusters/serverless/index.md b/doc/user/project/clusters/serverless/index.md index 9aaf046e78b736e78376228241e0802478c42a97..1dc543c3b830b8f579d5cb03646edef1e44d0957 100644 --- a/doc/user/project/clusters/serverless/index.md +++ b/doc/user/project/clusters/serverless/index.md @@ -43,7 +43,7 @@ To run Knative on GitLab, you will need: clone the sample [Knative Ruby App](https://gitlab.com/knative-examples/knative-ruby-app) to get started. 1. **Kubernetes Cluster:** An RBAC-enabled Kubernetes cluster is required to deploy Knative. - The simplest way to get started is to add a cluster using [GitLab's GKE integration](../add_remove_clusters.md#gke-cluster). + The simplest way to get started is to add a cluster using GitLab's [GKE integration](../add_remove_clusters.md). The set of minimum recommended cluster specifications to run Knative is 3 nodes, 6 vCPUs, and 22.50 GB memory. 1. **Helm Tiller:** Helm is a package manager for Kubernetes and is required to install Knative. diff --git a/doc/user/project/img/code_owners_approval_new_protected_branch_v12_4.png b/doc/user/project/img/code_owners_approval_new_protected_branch_v12_4.png index f813b60dcd923b105705dcafb0bf74b46bdddf05..fc2893aa4d569f66ee28741cb4c86c45457a0c73 100644 Binary files a/doc/user/project/img/code_owners_approval_new_protected_branch_v12_4.png and b/doc/user/project/img/code_owners_approval_new_protected_branch_v12_4.png differ diff --git a/doc/user/project/img/deploy_boards_landing_page.png b/doc/user/project/img/deploy_boards_landing_page.png index c9621a068605994adb68457052da6ee866162623..73b3724d6577e6256fd9619d2bc5159df4ce009f 100644 Binary files a/doc/user/project/img/deploy_boards_landing_page.png and b/doc/user/project/img/deploy_boards_landing_page.png differ diff --git a/doc/user/project/img/description_templates_issue_settings.png b/doc/user/project/img/description_templates_issue_settings.png index 533281088354102c9375c4adc6514bae8a814901..657b6ae126904deb1c7a3dc13f96c21ddfde1925 100644 Binary files a/doc/user/project/img/description_templates_issue_settings.png and b/doc/user/project/img/description_templates_issue_settings.png differ diff --git a/doc/user/project/img/description_templates_merge_request_settings.png b/doc/user/project/img/description_templates_merge_request_settings.png index eda264f7f3777ec0fa61ad8f35bcd7fb1dd88672..587367bf2fe5e7e35d54ecc9863a7a6acfc336f2 100644 Binary files a/doc/user/project/img/description_templates_merge_request_settings.png and b/doc/user/project/img/description_templates_merge_request_settings.png differ diff --git a/doc/user/project/img/issue_boards_multi_select.png b/doc/user/project/img/issue_boards_multi_select.png index 34ec0c1c58e6d011b26186b60794d64d6a808d24..eebe06b04ae36edea3aa84e5af4b8480f23a1dc4 100644 Binary files a/doc/user/project/img/issue_boards_multi_select.png and b/doc/user/project/img/issue_boards_multi_select.png differ diff --git a/doc/user/project/img/protected_branches_list_v12_3.png b/doc/user/project/img/protected_branches_list_v12_3.png index 2353ddd23beb838a093a6047c3cce8c8a1e40a90..995a294b85c22e2f36c0b2dac7bf2104f517e42c 100644 Binary files a/doc/user/project/img/protected_branches_list_v12_3.png and b/doc/user/project/img/protected_branches_list_v12_3.png differ diff --git a/doc/user/project/img/protected_branches_page_v12_3.png b/doc/user/project/img/protected_branches_page_v12_3.png index 9a194c85c41b03d72fee6c15bf0dbf269b72cf1f..60aa3c4d251b83c9ce6c2a737f212af62ea6d2ee 100644 Binary files a/doc/user/project/img/protected_branches_page_v12_3.png and b/doc/user/project/img/protected_branches_page_v12_3.png differ diff --git a/doc/user/project/img/service_desk_disabled.png b/doc/user/project/img/service_desk_disabled.png deleted file mode 100644 index ba11b5086821d9795051240611dd5ee76f0b2ab4..0000000000000000000000000000000000000000 Binary files a/doc/user/project/img/service_desk_disabled.png and /dev/null differ diff --git a/doc/user/project/img/service_desk_enabled.png b/doc/user/project/img/service_desk_enabled.png index aee2b53a68054c81943eb014a9c38a1a84b82a1e..33d51227e5f2c43cb8c49d2c29c7ae26fb7708bb 100644 Binary files a/doc/user/project/img/service_desk_enabled.png and b/doc/user/project/img/service_desk_enabled.png differ diff --git a/doc/user/project/img/service_desk_nav_item.png b/doc/user/project/img/service_desk_nav_item.png index 3420e355f674cbc58bea7d70b9d4221b9de35a8d..fdf8fa024c3d98dde12f990fb59a8efab59cdef1 100644 Binary files a/doc/user/project/img/service_desk_nav_item.png and b/doc/user/project/img/service_desk_nav_item.png differ diff --git a/doc/user/project/import/img/bitbucket_import_select_project_v12_3.png b/doc/user/project/import/img/bitbucket_import_select_project_v12_3.png index 1f1febd9068a8e3a51acf5149662e9e400ae90d5..bbc72a0b4b7c3c886e4cac1b394c50503c3f5b55 100644 Binary files a/doc/user/project/import/img/bitbucket_import_select_project_v12_3.png and b/doc/user/project/import/img/bitbucket_import_select_project_v12_3.png differ diff --git a/doc/user/project/import/img/bitbucket_server_import_select_project_v12_3.png b/doc/user/project/import/img/bitbucket_server_import_select_project_v12_3.png index 1c344853cc812d93ab3ea7a21adf1573982fbc9c..3f94dd83dd6b36ddadbed2fc70e5342f3eb075cc 100644 Binary files a/doc/user/project/import/img/bitbucket_server_import_select_project_v12_3.png and b/doc/user/project/import/img/bitbucket_server_import_select_project_v12_3.png differ diff --git a/doc/user/project/import/img/gitlab_new_project_page_v12_2.png b/doc/user/project/import/img/gitlab_new_project_page_v12_2.png index e79c27f32c07f687c431c522f62eb9abc8c62c9a..ff6e5dbf4a119f92bd3ef772c1afaa139dbc9291 100644 Binary files a/doc/user/project/import/img/gitlab_new_project_page_v12_2.png and b/doc/user/project/import/img/gitlab_new_project_page_v12_2.png differ diff --git a/doc/user/project/import/img/import_projects_from_gitea_importer_v12_3.png b/doc/user/project/import/img/import_projects_from_gitea_importer_v12_3.png index d8ae1a54851d285b830e83d217ba9c99bbc44ef8..0f99f74871bddd376f2ee0afb229a338157ed3f6 100644 Binary files a/doc/user/project/import/img/import_projects_from_gitea_importer_v12_3.png and b/doc/user/project/import/img/import_projects_from_gitea_importer_v12_3.png differ diff --git a/doc/user/project/import/img/import_projects_from_github_importer_v12_3.png b/doc/user/project/import/img/import_projects_from_github_importer_v12_3.png index 6a53d9e6d1d8d4dce25f796c5ddd387bf57b5a1c..3ac03c0ecc588b6e6d65a962bfb10d40e8667d62 100644 Binary files a/doc/user/project/import/img/import_projects_from_github_importer_v12_3.png and b/doc/user/project/import/img/import_projects_from_github_importer_v12_3.png differ diff --git a/doc/user/project/import/img/import_projects_from_repo_url.png b/doc/user/project/import/img/import_projects_from_repo_url.png index 90bcff5d31b03fb93eddb26b3a163e121e69d539..fd3eae98ebfe9f8b971d611cbdcd3c714517c2fe 100644 Binary files a/doc/user/project/import/img/import_projects_from_repo_url.png and b/doc/user/project/import/img/import_projects_from_repo_url.png differ diff --git a/doc/user/project/import/perforce.md b/doc/user/project/import/perforce.md index a08488a4baff24b8ab3d58d2d10025b62e4074a1..cbcef7a2fb07b46ff1598023fdfd5160d27aad21 100644 --- a/doc/user/project/import/perforce.md +++ b/doc/user/project/import/perforce.md @@ -19,7 +19,7 @@ Git: said 'You need to stop work on that new feature and fix this security vulnerability' you can do so very easily in Git. 1. Having a complete copy of the project and its history on your local machine - means every transaction is superfast and Git provides that. You can branch/merge + means every transaction is very fast and Git provides that. You can branch/merge and experiment in isolation, then clean up your mess before sharing your new cool stuff with everyone. 1. Git also made code review simple because you could share your changes without diff --git a/doc/user/project/import/phabricator.md b/doc/user/project/import/phabricator.md index 5015c5390de9aabbd06160f930653686d60664d1..46438c81d35c136fd5eb64cf72f8c2fa11ebd9a1 100644 --- a/doc/user/project/import/phabricator.md +++ b/doc/user/project/import/phabricator.md @@ -32,4 +32,4 @@ we can gain early feedback before releasing it for everyone. To enable it: Feature.enable(:phabricator_import) ``` -1. Enable Phabricator as an [import source](../../admin_area/settings/visibility_and_access_controls.md#import-sources) in the Admin area. +1. Enable Phabricator as an [import source](../../admin_area/settings/visibility_and_access_controls.md#import-sources) in the Admin Area. diff --git a/doc/user/project/integrations/generic_alerts.md b/doc/user/project/integrations/generic_alerts.md index 62310dd9177bb2f6473c6765c13aa772fe0c36ee..8c509f30c4fc991c4811a65b7542fafa721daa2c 100644 --- a/doc/user/project/integrations/generic_alerts.md +++ b/doc/user/project/integrations/generic_alerts.md @@ -30,7 +30,7 @@ You can customize the payload by sending the following parameters. All fields ar | `start_time` | DateTime | The time of the incident. If none is provided, a timestamp of the issue will be used. | | `service` | String | The affected service. | | `monitoring_tool` | String | The name of the associated monitoring tool. | -| `hosts` | String or Array | One or more hosts, as to where this incident ocurred. | +| `hosts` | String or Array | One or more hosts, as to where this incident occurred. | Example request: diff --git a/doc/user/project/integrations/img/emails_on_push_service.png b/doc/user/project/integrations/img/emails_on_push_service.png index 84e42eca9a2246828939264682462fb4cebf5078..43cef1673696169d6afa67d07eb691f3ca80b96c 100644 Binary files a/doc/user/project/integrations/img/emails_on_push_service.png and b/doc/user/project/integrations/img/emails_on_push_service.png differ diff --git a/doc/user/project/integrations/img/embed_metrics_issue_template.png b/doc/user/project/integrations/img/embed_metrics_issue_template.png index 3c6a243e5c1ca9de4eb4211489edeaa9d0528910..ca39a738d5f8f181ae91da4eafc67cf756733c88 100644 Binary files a/doc/user/project/integrations/img/embed_metrics_issue_template.png and b/doc/user/project/integrations/img/embed_metrics_issue_template.png differ diff --git a/doc/user/project/integrations/img/hangouts_chat_configuration.png b/doc/user/project/integrations/img/hangouts_chat_configuration.png index c40c9b92edb84f2d15e4e9e37d154582704d8807..1104f20ce2f78421ce7fa2229005d7d99bca48ac 100644 Binary files a/doc/user/project/integrations/img/hangouts_chat_configuration.png and b/doc/user/project/integrations/img/hangouts_chat_configuration.png differ diff --git a/doc/user/project/integrations/img/mattermost_configuration.png b/doc/user/project/integrations/img/mattermost_configuration.png index d196b1fc8e4a69455c275e902a431f510323eaa3..18c0036846d6d2951e041f2ecc4fe1a041c1b45a 100644 Binary files a/doc/user/project/integrations/img/mattermost_configuration.png and b/doc/user/project/integrations/img/mattermost_configuration.png differ diff --git a/doc/user/project/integrations/img/microsoft_teams_configuration.png b/doc/user/project/integrations/img/microsoft_teams_configuration.png index 8794991f2ecc188447464a9c37e66cf379cd3d99..22ad28e3f73d73f367c5c9072edd2e2a2307752c 100644 Binary files a/doc/user/project/integrations/img/microsoft_teams_configuration.png and b/doc/user/project/integrations/img/microsoft_teams_configuration.png differ diff --git a/doc/user/project/integrations/img/slack_configuration.png b/doc/user/project/integrations/img/slack_configuration.png index 6922c70f253ea64d6d19ec5aab123062099fff65..4d5e6ae78561d4d0ff851322080e1faac76781dc 100644 Binary files a/doc/user/project/integrations/img/slack_configuration.png and b/doc/user/project/integrations/img/slack_configuration.png differ diff --git a/doc/user/project/integrations/img/unify_circuit_configuration.png b/doc/user/project/integrations/img/unify_circuit_configuration.png index 285d4f92030ea25fa2609ad22d90a6c696159310..adba065347ff740ba7d823aabfababe17f56931c 100644 Binary files a/doc/user/project/integrations/img/unify_circuit_configuration.png and b/doc/user/project/integrations/img/unify_circuit_configuration.png differ diff --git a/doc/user/project/integrations/prometheus.md b/doc/user/project/integrations/prometheus.md index 3b7309ea7e4b4c63b9ebc0d4ecb81af3980cc264..17e64f1692d4e7c6887103dbbabdcd15ba2ed82c 100644 --- a/doc/user/project/integrations/prometheus.md +++ b/doc/user/project/integrations/prometheus.md @@ -139,7 +139,10 @@ GitLab supports a limited set of [CI variables](../../../ci/variables/README.htm - CI_ENVIRONMENT_SLUG - KUBE_NAMESPACE -To specify a variable in a query, enclose it in quotation marks with curly braces with a leading percent. For example: `"%{ci_environment_slug}"`. +There are 2 methods to specify a variable in a query or dashboard: + +1. Variables can be specified using the [Liquid template format](https://help.shopify.com/en/themes/liquid/basics), for example `{{ci_environment_slug}}` ([added](https://gitlab.com/gitlab-org/gitlab/merge_requests/20793) in GitLab 12.6). +1. You can also enclose it in quotation marks with curly braces with a leading percent, for example `"%{ci_environment_slug}"`. This method is deprecated though and support will be [removed in the next major release](https://gitlab.com/gitlab-org/gitlab/issues/37990). ### Defining custom dashboards per project @@ -152,12 +155,13 @@ NOTE: **Note:** The custom metrics as defined below do not support alerts, unlike [additional metrics](#adding-additional-metrics-premium). -Dashboards have several components: +#### Adding a new dashboard to your project -- Panel groups, which comprise panels. -- Panels, which support one or more metrics. +You can configure a custom dashboard by adding a new `.yml` file into a project's repository. Only `.yml` files present in the projects **default** branch are displayed on the project's **Operations > Metrics** section. + +You may create a new file from scratch or duplicate a GitLab-defined dashboard. -To configure a custom dashboard: +**Add a `.yml` file manually** 1. Create a YAML file with the `.yml` extension under your repository's root directory inside `.gitlab/dashboards/`. For example, create @@ -182,7 +186,7 @@ To configure a custom dashboard: define the layout of the dashboard and the Prometheus queries used to populate data. -1. Save the file, commit, and push to your repository. +1. Save the file, commit, and push to your repository. The file must be present in your **default** branch. 1. Navigate to your project's **Operations > Metrics** and choose the custom dashboard from the dropdown. @@ -190,6 +194,28 @@ NOTE: **Note:** Configuration files nested under subdirectories of `.gitlab/dashboards` are not supported and will not be available in the UI. +**Duplicate a GitLab-defined dashboard as a new `.yml` file** + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/37238) in GitLab 12.7. + +You can save a copy of a GitLab defined dashboard that can be customized and adapted to your project. You can decide to save the dashboard new `.yml` file in the project's **default** branch or in a newly created branch with a name of your choosing. + +1. Click on the "Duplicate dashboard" in the dashboard dropdown. + + NOTE:**Note:** + Only GitLab-defined dashboards can be duplicated. + +1. Input the file name and other information, such as a new commit message, and click on "Duplicate". + +If you select your **default** branch, the new dashboard will become immediately available. If you select another branch, this branch should be merged to your **default** branch first. + +#### Dashboard YAML properties + +Dashboards have several components: + +- Panel groups, which comprise of panels. +- Panels, which support one or more metrics. + The following tables outline the details of expected properties. **Dashboard properties:** diff --git a/doc/user/project/integrations/prometheus_library/nginx.md b/doc/user/project/integrations/prometheus_library/nginx.md index cf46456ca42a315ec622e6b9228ad704b55e7559..eda8cf35091197a28cae45c0cedb17f90a44b930 100644 --- a/doc/user/project/integrations/prometheus_library/nginx.md +++ b/doc/user/project/integrations/prometheus_library/nginx.md @@ -20,7 +20,7 @@ NGINX server metrics are detected, which tracks the pages and content directly s ## Configuring Prometheus to monitor for NGINX metrics -To get started with NGINX monitoring, you should first enable the [VTS statistics](https://github.com/vozlt/nginx-module-vts)) module for your NGINX server. This will capture and display statistics in an HTML readable form. Next, you should install and configure the [NGINX VTS exporter](https://github.com/hnlq715/nginx-vts-exporter) which parses these statistics and translates them into a Prometheus monitoring endpoint. +To get started with NGINX monitoring, you should first enable the [VTS statistics](https://github.com/vozlt/nginx-module-vts) module for your NGINX server. This will capture and display statistics in an HTML readable form. Next, you should install and configure the [NGINX VTS exporter](https://github.com/hnlq715/nginx-vts-exporter) which parses these statistics and translates them into a Prometheus monitoring endpoint. If you are using NGINX as your Kubernetes Ingress, GitLab will [automatically detect](nginx_ingress.md) the metrics once enabled in 0.9.0 and later releases. diff --git a/doc/user/project/integrations/services_templates.md b/doc/user/project/integrations/services_templates.md index a0bf31c526f229a8f1d7f40f7b557cebbcf9ee6b..8a88df886294b4691b92409d09b61dd5fec20d2d 100644 --- a/doc/user/project/integrations/services_templates.md +++ b/doc/user/project/integrations/services_templates.md @@ -8,8 +8,8 @@ for new projects only. ## Enable a service template -In GitLab's Admin area, navigate to **Service Templates** and choose the -service template you wish to create. +Navigate to the **Admin Area > Service Templates** and choose the service +template you wish to create. ## Services for external issue trackers diff --git a/doc/user/project/integrations/webhooks.md b/doc/user/project/integrations/webhooks.md index bb946574371e3be877158a7b2dc0c1c6a455f200..79cda9a045edcd7b54c60dee334488470851c855 100644 --- a/doc/user/project/integrations/webhooks.md +++ b/doc/user/project/integrations/webhooks.md @@ -56,9 +56,9 @@ tier](https://about.gitlab.com/pricing/), as shown in the following table: | Tier | Number of webhooks per project | |----------|--------------------------------| -| Free | 10 | -| Bronze | 20 | -| Silver | 30 | +| Free | 100 | +| Bronze | 100 | +| Silver | 100 | | Gold | 100 | ## Use-cases diff --git a/doc/user/project/issue_board.md b/doc/user/project/issue_board.md index 403972941b2907a61c7a55e4bfe57e5c3f23bc9d..b1334f0b0b04956ca4e1db489808816a2dfec099 100644 --- a/doc/user/project/issue_board.md +++ b/doc/user/project/issue_board.md @@ -281,6 +281,17 @@ As on another list types, click on the trash icon to remove it.  +## Work In Progress limits **(STARTER)** + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/11403) in GitLab 12.7 + +You can set Work In Progress (WIP) limits per issues list. When a limit is set, the list's header shows the number of issues in the list and the soft limit of issues. For example, for a list with 4 issues, and a limit of 5, the header will show `4/5`. If you exceed the limit, the current number of issues is shown in red. For example, you have a list with 5 issues with a limit of 5. When you move another issue to that list, the list's header displays `6/5`, with the `6` shown in red. + +To set a WIP limit for a list: + +1. Navigate to a Project or Group board for which you have membership and click on the Settings icon (gear) in a list's header. +1. Next to **Work In Progress Limit**, click **Edit** and enter the maximum number of issues. Press `Enter` to save. + ### Summary of features per tier Different issue board features are available in different [GitLab tiers](https://about.gitlab.com/pricing/), as shown in the following table: diff --git a/doc/user/project/issues/csv_export.md b/doc/user/project/issues/csv_export.md index b97bcd47f61cebf8c5aa9c5899ec898591ebfde7..13f0c11399f11690c2412870f5c362b6a4feb70f 100644 --- a/doc/user/project/issues/csv_export.md +++ b/doc/user/project/issues/csv_export.md @@ -69,6 +69,8 @@ Data will be encoded with a comma as the column delimiter, with `"` used to quot | Labels | Title of any labels joined with a `,` | | Time Estimate | [Time estimate](../time_tracking.md#estimates) in seconds | | Time Spent | [Time spent](../time_tracking.md#time-spent) in seconds | +| Epic ID | Id of the parent epic **(ULTIMATE)**, introduced in 12.7 | +| Epic Title | Title of the parent epic **(ULTIMATE)**, introduced in 12.7 | ## Limitations diff --git a/doc/user/project/issues/design_management.md b/doc/user/project/issues/design_management.md index 594f73dbfbedfd085310e5ad895777fd51040bb7..c5358f338a54792aacb9ed9a6566fd60b6cd104e 100644 --- a/doc/user/project/issues/design_management.md +++ b/doc/user/project/issues/design_management.md @@ -37,6 +37,13 @@ Design Management requires that projects are using [hashed storage](../../../administration/repository_storage_types.html#hashed-storage) (the default storage type since v10.0). +### Feature Flags + +- Reference Parsing + + Designs support short references in Markdown, but this needs to be enabled by setting + the `:design_management_reference_filter_gfm_pipeline` feature flag. + ## Limitations - Files uploaded must have a file extension of either `png`, `jpg`, `jpeg`, `gif`, `bmp`, `tiff` or `ico`. @@ -71,6 +78,8 @@ Designs cannot be added if the issue has been moved, or its ## Viewing designs Images on the Design Management page can be enlarged by clicking on them. +You can navigate through designs by clicking on the navigation buttons on the +top-right corner or with <kbd>Left</kbd>/<kbd>Right</kbd> keyboard buttons. The number of comments on a design — if any — is listed to the right of the design filename. Clicking on this number enlarges the design @@ -84,6 +93,14 @@ to help summarize changes between versions. | Modified (in the selected version) |  | | Added (in the selected version) |  | +### Exploring designs by zooming + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/13217) in [GitLab Premium](https://about.gitlab.com/pricing/) 12.7. + +Designs can be explored in greater detail by zooming in and out of the image. Control the amount of zoom with the `+` and `-` buttons at the bottom of the image. While zoomed, you can still [add new annotations](#adding-annotations-to-designs) to the image, and see any existing ones. + + + ## Deleting designs > [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/11089) in [GitLab Premium](https://about.gitlab.com/pricing/) 12.4. @@ -127,3 +144,32 @@ Different discussions have different badge numbers: From GitLab 12.5 on, new annotations will be outputted to the issue activity, so that everyone involved can participate in the discussion. + +## References + +GitLab Flavored Markdown supports references to designs. The syntax for this is: + + `#123[file.jpg]` - the issue reference, with the filename in square braces + +File names may contain a variety of odd characters, so two escaping mechanisms are supported: + +### Quoting + +File names may be quoted with double quotation marks, eg: + + `#123["file.jpg"]` + +This is useful if, for instance, your filename has square braces in its name. In this scheme, all +double quotation marks in the file name need to be escaped with backslashes, and backslashes need +to be escaped likewise: + + `#123["with with \"quote\" marks and a backslash \\.png"]` + +### Base64 Encoding + +In the case of file names that include HTML elements, you will need to escape these names to avoid +them being processed as HTML literals. To do this, we support base64 encoding, eg. + + The file `<a>.jpg` can be referenced as `#123[base64:PGE+LmpwZwo=]` + +Obviously we would advise against using such filenames. diff --git a/doc/user/project/issues/img/confirm_design_deletion_v12_4.png b/doc/user/project/issues/img/confirm_design_deletion_v12_4.png index b1a55c639ca74e8ae024bf7c859bee1d09e2ce08..447d3907122457c33210093cac4dcc35187fcecb 100644 Binary files a/doc/user/project/issues/img/confirm_design_deletion_v12_4.png and b/doc/user/project/issues/img/confirm_design_deletion_v12_4.png differ diff --git a/doc/user/project/issues/img/delete_multiple_designs_v12_4.png b/doc/user/project/issues/img/delete_multiple_designs_v12_4.png index b421a5577df30a8f3f285fbb8853cc75f9ccd62c..75cbdf77c2453bdf70446c4500d3eabed3bbce9f 100644 Binary files a/doc/user/project/issues/img/delete_multiple_designs_v12_4.png and b/doc/user/project/issues/img/delete_multiple_designs_v12_4.png differ diff --git a/doc/user/project/issues/img/delete_single_design_v12_4.png b/doc/user/project/issues/img/delete_single_design_v12_4.png index 0ca03b48e7674e854f91e70d044f61c362c7b4e2..158b4949ce4fe0da22f5b2150b0aa06af4f0bef8 100644 Binary files a/doc/user/project/issues/img/delete_single_design_v12_4.png and b/doc/user/project/issues/img/delete_single_design_v12_4.png differ diff --git a/doc/user/project/issues/img/design_zooming_v12_7.png b/doc/user/project/issues/img/design_zooming_v12_7.png new file mode 100644 index 0000000000000000000000000000000000000000..4acb4e109132c2e53266915c75ebabdb3bd81455 Binary files /dev/null and b/doc/user/project/issues/img/design_zooming_v12_7.png differ diff --git a/doc/user/project/issues/img/disable_issue_auto_close.png b/doc/user/project/issues/img/disable_issue_auto_close.png new file mode 100644 index 0000000000000000000000000000000000000000..5894d39622a0ac83f40baa7bfa7a3d2c0f184169 Binary files /dev/null and b/doc/user/project/issues/img/disable_issue_auto_close.png differ diff --git a/doc/user/project/issues/img/select_designs_v12_4.png b/doc/user/project/issues/img/select_designs_v12_4.png index a53bd516300d66ced1f9c4013e8505d671449ad8..532a79fce65c997cd43eb20cacc31bdf7a184762 100644 Binary files a/doc/user/project/issues/img/select_designs_v12_4.png and b/doc/user/project/issues/img/select_designs_v12_4.png differ diff --git a/doc/user/project/issues/img/zoom-quickaction-button.png b/doc/user/project/issues/img/zoom-quickaction-button.png index c95a56b43e8bf935e34e3d9dc5ff64043dc0e718..3be4f36f88fde773ab7e3485a679bbf091513c71 100644 Binary files a/doc/user/project/issues/img/zoom-quickaction-button.png and b/doc/user/project/issues/img/zoom-quickaction-button.png differ diff --git a/doc/user/project/issues/index.md b/doc/user/project/issues/index.md index 6abd6fd7047c6fcb82b793c5436867332935170b..f540dfe0e51f6e39386b614e4d81184fbb724792 100644 --- a/doc/user/project/issues/index.md +++ b/doc/user/project/issues/index.md @@ -97,7 +97,7 @@ and modify them if you have the necessary [permissions](../../permissions.md). On the Issues List, you can view all issues in the current project, or from multiple projects when opening the Issues List from the higher-level group context. Filter the -issue list with a [search query](../../search/index.md#issues-and-merge-requests-per-project), +issue list with a [search query](../../search/index.md#filtering-issue-and-merge-request-lists), including specific metadata, such as label(s), assignees(s), status, and more. From this view, you can also make certain changes [in bulk](../bulk_editing.md) to the displayed issues. diff --git a/doc/user/project/issues/managing_issues.md b/doc/user/project/issues/managing_issues.md index 38649b05593296e6766740b2e93cab8a57af8f9c..ff360e973aa4a8006ecee86da5a6c0a0ee8e5f58 100644 --- a/doc/user/project/issues/managing_issues.md +++ b/doc/user/project/issues/managing_issues.md @@ -66,7 +66,7 @@ configured. When you click this link, an email address is generated and displayed, which should be used by **you only**, to create issues in this project. You can save this address as a -contact in your email client for easy acceess. +contact in your email client for easy access. CAUTION: **Caution:** This is a private email address, generated just for you. **Keep it to yourself**, @@ -207,10 +207,23 @@ and https://gitlab.example.com/group/otherproject/issues/23. ``` will close `#18`, `#19`, `#20`, and `#21` in the project this commit is pushed to, -as well as `#22` and `#23` in group/otherproject. `#17` won't be closed as it does +as well as `#22` and `#23` in `group/otherproject`. `#17` won't be closed as it does not match the pattern. It works with multi-line commit messages as well as one-liners when used from the command line with `git commit -m`. +#### Disabling automatic issue closing + +The automatic issue closing feature can be disabled on a per-project basis +within the [project's repository settings](../settings/index.md). Referenced +issues will still be displayed as such but won't be closed automatically. + + + +This only applies to issues affected by new merge requests or commits. Already +closed issues remain as-is. Disabling automatic issue closing only affects merge +requests *within* the project and won't prevent other projects from closing it +via cross-project issues. + #### Customizing the issue closing pattern **(CORE ONLY)** In order to change the default issue closing pattern, GitLab administrators must edit the diff --git a/doc/user/project/members/img/project_members.png b/doc/user/project/members/img/project_members.png index 5d44b5d957ec48da124064a6683ee2871dad1598..218f5a24d2e61c32e05f1075e9ef23c1561a1eb8 100644 Binary files a/doc/user/project/members/img/project_members.png and b/doc/user/project/members/img/project_members.png differ diff --git a/doc/user/project/members/img/project_members_filter_v12_6.png b/doc/user/project/members/img/project_members_filter_v12_6.png index 0207515ded0f4a77a43e11ab35da4c0949d7fef7..692fdfe00a1cf0e0a9814d0748557e5659ce05d2 100644 Binary files a/doc/user/project/members/img/project_members_filter_v12_6.png and b/doc/user/project/members/img/project_members_filter_v12_6.png differ diff --git a/doc/user/project/members/index.md b/doc/user/project/members/index.md index c069882e38f2f1075a4bdb0fc42efa02300547f6..27a5701e6c2609240e5cc198ea7215a1efea3959 100644 --- a/doc/user/project/members/index.md +++ b/doc/user/project/members/index.md @@ -27,8 +27,8 @@ From the image above, we can deduce the following things: - Administrator is the Owner and member of **all** groups and for that reason, there is an indication of an ancestor group and inherited Owner permissions. -[From](https://gitlab.com/gitlab-org/gitlab/issues/21727), you can filter this list -using dropdown on the right side: +[From GitLab 12.6](https://gitlab.com/gitlab-org/gitlab/issues/21727), you can filter this list +using the dropdown on the right side:  diff --git a/doc/user/project/merge_requests/code_quality.md b/doc/user/project/merge_requests/code_quality.md index 69bdfe10e3ffd6f5f55f5f2258bed0f22898b7fb..9d44f4166964657d7a11302c587fde39fdaeb583 100644 --- a/doc/user/project/merge_requests/code_quality.md +++ b/doc/user/project/merge_requests/code_quality.md @@ -66,6 +66,19 @@ will scan your source code for code quality issues. The report will be saved as that you can later download and analyze. Due to implementation limitations we always take the latest Code Quality artifact available. +It is also possible to override the URL to the Code Quality image by +setting the `CODE_QUALITY_IMAGE` variable. This is particularly useful if you want +to lock in a specific version of Code Quality, or use a fork of it: + +```yaml +include: + - template: Code-Quality.gitlab-ci.yml + +code_quality: + variables: + CODE_QUALITY_IMAGE: "registry.example.com/codequality-fork:latest" +``` + By default, report artifacts are not downloadable. If you need them downloadable on the job details page, you can add `gl-code-quality-report.json` to the artifact paths like so: @@ -125,6 +138,33 @@ code_quality: codequality: gl-code-quality-report.json ``` +In GitLab 12.6, Code Quality switched to the +[new versioning scheme](https://gitlab.com/gitlab-org/security-products/codequality/merge_requests/38). +It is highly recommended to include the Code Quality template as shown in the +[example configuration](#example-configuration), which uses the new versioning scheme. +If not using the template, the `SP_VERSION` variable can be hardcoded to use the +new image versions: + +```yaml +code_quality: + image: docker:stable + variables: + DOCKER_DRIVER: overlay2 + SP_VERSION: 0.85.6 + allow_failure: true + services: + - docker:stable-dind + script: + - docker run + --env SOURCE_CODE="$PWD" + --volume "$PWD":/code + --volume /var/run/docker.sock:/var/run/docker.sock + "registry.gitlab.com/gitlab-org/security-products/codequality:$SP_VERSION" /code + artifacts: + reports: + codequality: gl-code-quality-report.json +``` + For GitLab 11.4 and earlier, the job should look like: ```yaml diff --git a/doc/user/project/merge_requests/creating_merge_requests.md b/doc/user/project/merge_requests/creating_merge_requests.md index 1dec58a8bb085cd998f16225f8f2ac85bdde162e..5b4c6d22c80cb823d69110bb61551e6571441f38 100644 --- a/doc/user/project/merge_requests/creating_merge_requests.md +++ b/doc/user/project/merge_requests/creating_merge_requests.md @@ -1,124 +1,165 @@ --- -type: index, reference +type: howto +description: "How to create Merge Requests in GitLab." +disqus_identifier: 'https://docs.gitlab.com/ee/gitlab-basics/add-merge-request.html' --- -# Creating merge requests +# How to create a merge request -Merge requests are the primary method of making changes to files in a GitLab project. -Changes are proposed by creating and submitting a merge request, which is then -[reviewed, and accepted (or rejected)](reviewing_and_managing_merge_requests.md), -all within GitLab. +Before creating a merge request, read through an +[introduction to Merge Requests](getting_started.md) +to familiarize yourself with the concept, the terminology, +and to learn what you can do with them. -## Creating new merge requests +Every merge request starts by creating a branch. You can either +do it locally through the command line, via a Git CLI application, +or through the GitLab UI. -You can start creating a new merge request by clicking the **New merge request** button -on the **Merge Requests** page in a project. Then you must choose the source project and -branch that contain your changes, and the target project and branch where you want to merge -the changes into. Click on **Compare branches and continue** to go to the next step -and start filling in the merge request details. +This document describes the several ways to create a merge request. -When viewing the commits on a branch other than master in **Repository > Commits**, you -can click on the **Create merge request** button, and a new merge request will be started -using the current branch as the source, and `master` in the current project as the target. +When you start a new merge request, regarless of the method, +you'll be taken to the [**New Merge Request** page](#new-merge-request-page) +to fill it with information about the merge request. -If you have recently pushed changes to GitLab, the **Create merge request** button will -also appear in the top right of the: +If you push a new branch to GitLab, also regardless of the method, +you can click the [**Create Merge Request**](#create-merge-request-button) +button and start a merge request from there. + +## New Merge Request page + +On the **New Merge Request** page, start by filling in the title +and description for the merge request. If there are are already +commits on the branch, the title will be pre-filled with the first +line of the first commit message, and the description will be +pre-filled with any additional lines in the commit message. +The title is the only field that is mandatory in all cases. + +From there, you can fill it with information (title, description, +assignee(s), milestone, labels, approvers) and click **Create Merge Request**. + +From that initial screen, you can also see all the commits, +pipelines, and file changes pushed to your branch before submitting +the merge request. + + + +TIP: **Tip:** +You can push one or more times to your branch in GitLab before +creating the merge request. + +## Create Merge Request button + +Once you have pushed a new branch to GitLab, visit your repository +in GitLab and to see a call-to-action at the top of your screen +from which you can click the button **Create Merge Request**. + + + +You can also see the **Create merge request** button in the top-right of the: - **Project** page. - **Repository > Files** page. - **Merge Requests** page. -In this case, the merge request will use the most recent branch you pushed changes -to as the source branch, and `master` in the current project as the target. +In this case, GitLab will use the most recent branch you pushed +changes to as the source branch, and the default branch in the current +project as the target. -You can also [create a new merge request directly from an issue](../repository/web_editor.md#create-a-new-branch-from-an-issue). +## New merge request by adding, editing, and uploading a file -## Workflow for new merge requests +When you choose to edit, add, or upload a file through the GitLab UI, +at the end of the file you'll see the option to add the **Commit message**, +to select the **Target branch** of that commit, and the checkbox to +**Start new a merge request with these changes**. -On the **New Merge Request** page, you can start by filling in the title and description -for the merge request. If there are are already commits on the branch, the title will -be pre-filled with the first line of the first commit message, and the description will -be pre-filled with any additional lines in the commit message. The title is the only -field that is mandatory in all cases. +Similarly, if you change files through the Web IDE, when you navigate to **Commit** on the left-hand sidebar, you'll see these same options. -From here, you can also: +Once you have added, edited, or uploaded the file: -- Set the merge request as a [work in progress](work_in_progress_merge_requests.md). -- Select the [assignee](#assignee), or [assignees](#multiple-assignees-starter). **(STARTER)** -- Select a [milestone](../milestones/index.md). -- Select [labels](../labels.md). -- Add any [merge request dependencies](merge_request_dependencies.md). **(PREMIUM)** -- Select [approval options](merge_request_approvals.md). **(STARTER)** -- Verify the source and target branches are correct. -- Enable the [delete source branch when merge request is accepted](#deleting-the-source-branch) option. -- Enable the [squash commits when merge request is accepted](squash_and_merge.md) option. -- If the merge request is from a fork, enable [Allow collaboration on merge requests across forks](allow_collaboration.md). +1. Describe your changes in the commit message. +1. Select an existing branch to add your commit into, or, if you'd like to create a new branch, type the new branch name (without spaces, capital letters, or special chars). +1. Keep the checkbox checked to start a new merge request straightaway, or, uncheck it to add more changes to that branch before starting the merge request. +1. Click **Commit changes**. -Many of these can be set when pushing changes from the command line, with -[Git push options](../push_options.md). +If you chose to start a merge request, you'll be taken to the +[**New Merge Request** page](#new-merge-request-page), from +which you can fill it in with information and submit the merge request. -### Merge requests to close issues +The merge request will target the default branch of the repository. +If you want to change it, you can do it later by editing the merge request. -If the merge request is being created to resolve an issue, you can add a note in the -description which will set it to [automatically close the issue](../issues/managing_issues.md#closing-issues-automatically) -when merged. +## New merge request from a new branch created through the UI -If the issue is [confidential](../issues/confidential_issues.md), you may want to -use a different workflow for [merge requests for confidential issues](../issues/confidential_issues.md#merge-requests-for-confidential-issues), -to prevent confidential information from being exposed. +To quickly start working on files through the GitLab UI, +navigate to your project's **Repository > Branches** and click +**New branch**. A new branch will be created and you can start +editing files. -## Assignee +Once committed and pushed, you can click on the [**Create Merge Request**](#create-merge-request-button) +button to open the [**New Merge Request** page](#new-merge-request-page). +A new merge request will be started using the current branch as the source, +and the default branch in the current project as the target. -Choose an assignee to designate someone as the person responsible for the first -[review of the merge request](reviewing_and_managing_merge_requests.md). Open the -drop down box to search for the user you wish to assign, and the merge request will be -added to their [assigned merge request list](../../search/index.md#issues-and-merge-requests). +## New merge request from you local environment -### Multiple assignees **(STARTER)** +Assuming you have your repository cloned into your computer and you'd +like to start working on changes to files, start by creating and +checking out a new branch: -> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/2004) in [GitLab Starter 11.11](https://about.gitlab.com/pricing/). +```bash +git checkout -b my-new-branch +``` -Multiple people often review merge requests at the same time. GitLab allows you to -have multiple assignees for merge requests to indicate everyone that is reviewing or -accountable for it. +Work on your file changes, stage, and commit them: - +```bash +git add . +git commit -m "My commit message" +``` -To assign multiple assignees to a merge request: +Once you're done, [push your branch to GitLab](../../../gitlab-basics/start-using-git.md#send-changes-to-gitlabcom): -1. From a merge request, expand the right sidebar and locate the **Assignees** section. -1. Click on **Edit** and from the dropdown menu, select as many users as you want - to assign the merge request to. +```bash +git push origin my-new-branch +``` -Similarly, assignees are removed by deselecting them from the same dropdown menu. +In the output, GitLab will prompt you with a direct link for creating +a merge request: -It's also possible to manage multiple assignees: +```bash +... +remote: To create a merge request for docs-new-merge-request, visit: +remote: https://gitlab-instance.com/my-group/my-project/merge_requests/new?merge_request%5Bsource_branch%5D=my-new-branch +``` -- When creating a merge request. -- Using [quick actions](../quick_actions.md#quick-actions-for-issues-merge-requests-and-epics). +Copy that link and paste it in your browser, and the [**New Merge Request page**](#new-merge-request-page) +will be displayed. -## Deleting the source branch +There is also a number of [flags you can add to commands when pushing through the command line](../push_options.md) to reduce the need for editing merge requests manually through the UI. -When creating a merge request, select the "Delete source branch when merge -request accepted" option and the source branch will be deleted when the merge -request is merged. To make this option enabled by default for all new merge -requests, enable it in the [project's settings](../settings/index.md#merge-request-settings). +If you didn't push your branch to GitLab through the command line +(for example, you used a Git CLI application to push your changes), +you can create a merge request through the GitLab UI by clicking +the [**Create Merge Request**](#create-merge-request-button) button. -This option is also visible in an existing merge request next to the merge -request button and can be selected/deselected before merging. It's only visible -to users with [Maintainer permissions](../../permissions.md) in the source project. +## New merge request from an issue -If the user viewing the merge request does not have the correct permissions to -delete the source branch and the source branch is set for deletion, the merge -request widget will show the "Deletes source branch" text. +You can also [create a new merge request directly from an issue](../repository/web_editor.md#create-a-new-branch-from-an-issue). - +## New merge request from the Merge Requests page -## Create new merge requests by email +You can start creating a new merge request by clicking the +**New merge request** button on the **Merge Requests** page in a project. +Then choose the source project and branch that contain your changes, +and the target project and branch where you want to merge the changes into. +Click on **Compare branches and continue** to go to the +[**New Merge Request** page](#new-merge-request-page) and fill in the details. + +## New merge request by email **(CORE ONLY)** _This feature needs [incoming email](../../../administration/incoming_email.md) -to be configured by a GitLab administrator to be available for CE/EE users, and -it's available on GitLab.com._ +to be configured by a GitLab administrator to be available._ It isn't +available in GitLab.com. You can create a new merge request by sending an email to a user-specific email address. The address can be obtained on the merge requests page by clicking on @@ -131,7 +172,7 @@ this feature. If it's not enabled to your instance, you may ask your GitLab administrator to do so. This is a private email address, generated just for you. **Keep it to yourself** -as anyone who gets ahold of it can create issues or merge requests as if they were you. +as anyone who has it can create issues or merge requests as if they were you. You can add this address to your contact list for easy access.  @@ -156,3 +197,7 @@ created from the repository's HEAD or the specified target branch to apply the patches. The target branch can be specified using the [`/target_branch` quick action](../quick_actions.md). If the source branch already exists, the patches will be applied on top of it. + +## Reviewing and managing Merge Requests + +Once you have submitted a merge request, it can be [reviewed and managed](reviewing_and_managing_merge_requests.md) through GitLab. diff --git a/doc/user/project/merge_requests/getting_started.md b/doc/user/project/merge_requests/getting_started.md new file mode 100644 index 0000000000000000000000000000000000000000..0ab8d31403e2118682d597fa64a04f0d15955e2c --- /dev/null +++ b/doc/user/project/merge_requests/getting_started.md @@ -0,0 +1,151 @@ +--- +type: index, reference +description: "Getting started with Merge Requests." +--- + +# Getting started with Merge Requests + +A Merge Request (**MR**) is the basis of GitLab as a code +collaboration and version control. + +When working in a Git-based platform, you can use branching +strategies to collaborate on code. + +A repository is composed by its _default branch_, which contains +the major version of the codebase, from which you create minor +branches, also called _feature branches_, to propose changes to +the codebase without introducing them directly into the major +version of the codebase. + +Branching is especially important when collaborating with others, +avoiding changes to be pushed directly to the default branch +without prior reviews, tests, and approvals. + +When you create a new feature branch, change the files, and push +it to GitLab, you have the option to create a **Merge Request**, +which is essentially a _request_ to merge one branch into another. + +The branch you added your changes into is called _source branch_ +while the branch you request to merge your changes into is +called _target branch_. + +The target branch can be the default or any other branch, depending +on the branching strategies you choose. + +In a merge request, beyond visualizing the differences between the +original content and your proposed changes, you can execute a +[significant number of tasks](#what-you-can-do-with-merge-requests) +before concluding your work and merging the merge request. + +You can watch our [GitLab Flow video](https://www.youtube.com/watch?v=InKNIvky2KE) for +a quick overview of working with merge requests. + +## How to create a merge request + +Learn the various ways to [create a merge request](creating_merge_requests.md). + +## What you can do with merge requests + +When you start a new merge request, you can immediately include the following +options, or add them later by clicking the **Edit** button on the merge +request's page at the top-right side: + +- [Assign](#assignee) the merge request to a colleague for review. With GitLab Starter and higher tiers, you can [assign it to more than one person at a time](#multiple-assignees-starter). +- Set a [milestone](../milestones/index.md) to track time-sensitive changes. +- Add [labels](../labels.md) to help contextualize and filter your merge requests over time. +- Require [approval](merge_request_approvals.md) from your team. **(STARTER)** +- [Close issues automatically](#merge-requests-to-close-issues) when they are merged. +- Enable the [delete source branch when merge request is accepted](#deleting-the-source-branch) option to keep your repository clean. +- Enable the [squash commits when merge request is accepted](squash_and_merge.md) option to combine all the commits into one before merging, thus keep a clean commit history in your repository. +- Set the merge request as a [Work In Progress (WIP)](work_in_progress_merge_requests.md) to avoid accidental merges before it is ready. + +Once you have created the merge request, you can also: + +- [Discuss](../../discussions/index.md) your implementation with your team in the merge request thread. +- [Perform inline code reviews](reviewing_and_managing_merge_requests.md#perform-inline-code-reviews). +- Add [merge request dependencies](merge_request_dependencies.md) to restrict it to be merged only when other merge requests have been merged. **(PREMIUM)** +- Preview continuous integration [pipelines on the merge request widget](reviewing_and_managing_merge_requests.md#pipeline-status-in-merge-requests-widgets). +- Preview how your changes look directly on your deployed application with [Review Apps](reviewing_and_managing_merge_requests.md#live-preview-with-review-apps). +- [Allow collaboration on merge requests across forks](allow_collaboration.md). +- Perform a [Review](../../discussions/index.md#merge-request-reviews-premium) in order to create multiple comments on a diff and publish them once you're ready. **(PREMIUM)** +- Add [code suggestions](../../discussions/index.md#suggest-changes) to change the content of merge requests directly into merge request threads, and easily apply them to the codebase directly from the UI. +- Add a time estimation and the time spent with that merge request with [Time Tracking](../time_tracking.md#time-tracking). + +Many of these can be set when pushing changes from the command line, +with [Git push options](../push_options.md). + +See also other [features associated to merge requests](reviewing_and_managing_merge_requests.md#associated-features). + +### Assignee + +Choose an assignee to designate someone as the person responsible +for the first [review of the merge request](reviewing_and_managing_merge_requests.md). +Open the drop down box to search for the user you wish to assign, +and the merge request will be added to their +[assigned merge request list](../../search/index.md#issues-and-merge-requests). + +#### Multiple assignees **(STARTER)** + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/2004) in [GitLab Starter 11.11](https://about.gitlab.com/pricing/). + +Multiple people often review merge requests at the same time. +GitLab allows you to have multiple assignees for merge requests +to indicate everyone that is reviewing or accountable for it. + + + +To assign multiple assignees to a merge request: + +1. From a merge request, expand the right sidebar and locate the **Assignees** section. +1. Click on **Edit** and from the dropdown menu, select as many users as you want + to assign the merge request to. + +Similarly, assignees are removed by deselecting them from the same +dropdown menu. + +It is also possible to manage multiple assignees: + +- When creating a merge request. +- Using [quick actions](../quick_actions.md#quick-actions-for-issues-merge-requests-and-epics). + +### Merge requests to close issues + +If the merge request is being created to resolve an issue, you can +add a note in the description which sets it to +[automatically close the issue](../issues/managing_issues.md#closing-issues-automatically) +when merged. + +If the issue is [confidential](../issues/confidential_issues.md), +you may want to use a different workflow for +[merge requests for confidential issues](../issues/confidential_issues.md#merge-requests-for-confidential-issues) +to prevent confidential information from being exposed. + +### Deleting the source branch + +When creating a merge request, select the +**Delete source branch when merge request accepted** option, and the source +branch is deleted when the merge request is merged. To make this option +enabled by default for all new merge requests, enable it in the +[project's settings](../settings/index.md#merge-request-settings). + +This option is also visible in an existing merge request next to +the merge request button and can be selected or deselected before merging. +It is only visible to users with [Maintainer permissions](../../permissions.md) +in the source project. + +If the user viewing the merge request does not have the correct +permissions to delete the source branch and the source branch +is set for deletion, the merge request widget displays the +**Deletes source branch** text. + + + +## Recommendations and best practices for Merge Requests + +- When working locally in your branch, add multiple commits and only push when + you're done, so GitLab runs only one pipeline for all the commits pushed + at once. By doing so, you save pipeline minutes. +- Delete feature branches on merge or after merging them to keep your repository clean. +- Take one thing at a time and ship the smallest changes possible. By doing so, + you'll have faster reviews and your changes will be less prone to errors. +- Do not use capital letters nor special chars in branch names. diff --git a/doc/user/project/merge_requests/img/create_merge_request_button_v12_6.png b/doc/user/project/merge_requests/img/create_merge_request_button_v12_6.png new file mode 100644 index 0000000000000000000000000000000000000000..bcbee10e1b72f73915819f8a32641d0b6c34aab6 Binary files /dev/null and b/doc/user/project/merge_requests/img/create_merge_request_button_v12_6.png differ diff --git a/doc/user/project/merge_requests/img/dependencies_edit_inaccessible_v12_4.png b/doc/user/project/merge_requests/img/dependencies_edit_inaccessible_v12_4.png index 3699ffd16b49021b056e2b4cc917090bc30e9688..5ced2fa812f196c4944baf45e7fa5a4cc5e6f352 100644 Binary files a/doc/user/project/merge_requests/img/dependencies_edit_inaccessible_v12_4.png and b/doc/user/project/merge_requests/img/dependencies_edit_inaccessible_v12_4.png differ diff --git a/doc/user/project/merge_requests/img/dependencies_edit_v12_4.png b/doc/user/project/merge_requests/img/dependencies_edit_v12_4.png index beb452e80cf50fd36d6797c53d4d2d8ca440aa92..4edf06487940474924ada7fc2af0f6ca55d04e7c 100644 Binary files a/doc/user/project/merge_requests/img/dependencies_edit_v12_4.png and b/doc/user/project/merge_requests/img/dependencies_edit_v12_4.png differ diff --git a/doc/user/project/merge_requests/img/dependencies_view_v12_2.png b/doc/user/project/merge_requests/img/dependencies_view_v12_2.png index e00231c839b72ccc530d0c91b2b580674a46c3be..3dde15292c42e2a9253edf5d49b46c158d4b439a 100644 Binary files a/doc/user/project/merge_requests/img/dependencies_view_v12_2.png and b/doc/user/project/merge_requests/img/dependencies_view_v12_2.png differ diff --git a/doc/user/project/merge_requests/img/incrementally_expand_merge_request_diffs_v12_2.png b/doc/user/project/merge_requests/img/incrementally_expand_merge_request_diffs_v12_2.png index ee94dbdea5c3aae9f0a7c13e8486f40eb5dc27bf..e3a2ff7960c1dc4627d117bbcaccb02bdd2f3623 100644 Binary files a/doc/user/project/merge_requests/img/incrementally_expand_merge_request_diffs_v12_2.png and b/doc/user/project/merge_requests/img/incrementally_expand_merge_request_diffs_v12_2.png differ diff --git a/doc/user/project/merge_requests/img/merge_request_diff_v12_2.png b/doc/user/project/merge_requests/img/merge_request_diff_v12_2.png index e56fbb9750fec4b346e895d9459cb95252264ce2..7e23b7db3090040084ee1619590f48a6bf95d409 100644 Binary files a/doc/user/project/merge_requests/img/merge_request_diff_v12_2.png and b/doc/user/project/merge_requests/img/merge_request_diff_v12_2.png differ diff --git a/doc/user/project/merge_requests/img/new_merge_request_page_v12_6.png b/doc/user/project/merge_requests/img/new_merge_request_page_v12_6.png new file mode 100644 index 0000000000000000000000000000000000000000..c0f2ba261cbb55aa2f51936a93617c5757a0880d Binary files /dev/null and b/doc/user/project/merge_requests/img/new_merge_request_page_v12_6.png differ diff --git a/doc/user/project/merge_requests/index.md b/doc/user/project/merge_requests/index.md index 203a2949243b0af37510f894f2c909645672e3e7..0617e6bc74dfd52ef51dc5a1af6be70b847b7e52 100644 --- a/doc/user/project/merge_requests/index.md +++ b/doc/user/project/merge_requests/index.md @@ -50,6 +50,8 @@ collaborating with that MR. MRs also contain navigation tabs from which you can see the discussion happening on the thread, the list of commits, the list of pipelines and jobs, the code changes and inline code reviews. +To get started, read the [introduction to merge requests](getting_started.md). + ## Merge request navigation tabs at the top > [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/33813) in GitLab 12.6. This positioning is experimental. @@ -76,72 +78,11 @@ Feature.disable(:mr_tabs_position) ## Creating merge requests -While making changes to files in the `master` branch of a repository is possible, it is not -the common workflow. In most cases, a user will make changes in a [branch](https://git-scm.com/book/en/v2/Git-Branching-Branches-in-a-Nutshell#_git_branching), -then [create a merge request](creating_merge_requests.md) to request that the changes -be merged into another branch (often the `master` branch). - -It is then [reviewed](#reviewing-and-managing-merge-requests), possibly updated after -discussions and suggestions, and finally approved and merged into the target branch. -Creating and reviewing merge requests is one of the most fundamental parts of working -with GitLab. - -When [creating merge requests](creating_merge_requests.md), there are a number of features -to be aware of: - -| Feature | Description | -|-----------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| [Adding patches when creating a merge request via e-mail](creating_merge_requests.md#adding-patches-when-creating-a-merge-request-via-e-mail) | Add commits to a merge request created by e-mail, by adding patches as e-mail attachments. | -| [Allow collaboration on merge requests across forks](allow_collaboration.md) | Allows the maintainers of an upstream project to collaborate on a fork, to make fixes or rebase branches before merging, reducing the back and forth of accepting community contributions. | -| [Assignee](creating_merge_requests.md#assignee) | Add an assignee to indicate who is reviewing or accountable for it. | -| [Automatic issue closing](../../project/issues/managing_issues.md#closing-issues-automatically) | Set a merge request to close defined issues automatically as soon as it is merged. | -| [Create new merge requests by email](creating_merge_requests.md#create-new-merge-requests-by-email) | Create new merge requests by sending an email to a user-specific email address. | -| [Deleting the source branch](creating_merge_requests.md#deleting-the-source-branch) | Select the "Delete source branch when merge request accepted" option and the source branch will be deleted when the merge request is merged. | -| [Git push options](../push_options.md) | Use Git push options to create or update merge requests when pushing changes to GitLab with Git, without needing to use the GitLab interface. | -| [Labels](../../project/labels.md) | Organize your issues and merge requests consistently throughout the project. | -| [Merge request approvals](merge_request_approvals.md) **(STARTER)** | Set the number of necessary approvals and predefine a list of approvers that will need to approve every merge request in a project. | -| [Merge Request dependencies](merge_request_dependencies.md) **(PREMIUM)** | Specify that a merge request depends on other merge requests, enforcing a desired order of merging. | -| [Merge Requests for Confidential Issues](../issues/confidential_issues.md#merge-requests-for-confidential-issues) | Create merge requests to resolve confidential issues for preventing leakage or early release of sensitive data through regular merge requests. | -| [Milestones](../../project/milestones/index.md) | Track merge requests to achieve a broader goal in a certain period of time. | -| [Multiple assignees](creating_merge_requests.md#multiple-assignees-starter) **(STARTER)** | Have multiple assignees for merge requests to indicate everyone that is reviewing or accountable for it. | -| [Squash and merge](squash_and_merge.md) | Squash all changes present in a merge request into a single commit when merging, to allow for a neater commit history. | -| [Work In Progress merge requests](work_in_progress_merge_requests.md) | Prevent the merge request from being merged before it's ready | +Learn [how to create a merge request](creating_merge_requests.md). ## Reviewing and managing merge requests -Once a merge request has been [created](#creating-merge-requests) and submitted, there -are many powerful features that you can use during the review process to make sure only -the changes you want are merged into the repository. - -For managers and administrators, it is also important to be able to view and manage -all the merge requests in a group or project. When [reviewing or managing merge requests](reviewing_and_managing_merge_requests.md), -there are a number of features to be aware of: - -| Feature | Description | -|-------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------| -| [Bulk editing merge requests](../../project/bulk_editing.md) | Update the attributes of multiple merge requests simultaneously. | -| [Cherry-pick changes](cherry_pick_changes.md) | Cherry-pick any commit in the UI by simply clicking the **Cherry-pick** button in a merged merge requests or a commit. | -| [Commenting on any file line in merge requests](reviewing_and_managing_merge_requests.md#commenting-on-any-file-line-in-merge-requests) | Make comments directly on the exact line of a file you want to talk about. | -| [Discuss changes in threads in merge requests reviews](../../discussions/index.md) | Keep track of the progress during a code review by making and resolving comments. | -| [Fast-forward merge requests](fast_forward_merge.md) | For a linear Git history and a way to accept merge requests without creating merge commits | -| [Find the merge request that introduced a change](versions.md) | When viewing the commit details page, GitLab will link to the merge request(s) containing that commit. | -| [Ignore whitespace changes in Merge Request diff view](reviewing_and_managing_merge_requests.md#ignore-whitespace-changes-in-Merge-Request-diff-view) | Hide whitespace changes from the diff view for a to focus on more important changes. | -| [Incrementally expand merge request diffs](reviewing_and_managing_merge_requests.md#incrementally-expand-merge-request-diffs) | View the content directly above or below a change, to better understand the context of that change. | -| [Live preview with Review Apps](reviewing_and_managing_merge_requests.md#live-preview-with-review-apps) | Live preview the changes when Review Apps are configured for your project | -| [Merge request diff file navigation](reviewing_and_managing_merge_requests.md#merge-request-diff-file-navigation) | Quickly jump to any changed file within the diff view. | -| [Merge requests versions](versions.md) | Select and compare the different versions of merge request diffs | -| [Merge when pipeline succeeds](merge_when_pipeline_succeeds.md) | Set a merge request that looks ready to merge to merge automatically when CI pipeline succeeds. | -| [Perform a Review](../../discussions/index.md#merge-request-reviews-premium) **(PREMIUM)** | Start a review in order to create multiple comments on a diff and publish them once you're ready. | -| [Pipeline status in merge requests](reviewing_and_managing_merge_requests.md#pipeline-status-in-merge-requests) | If using [GitLab CI/CD](../../../ci/README.md), see pre and post-merge pipelines information, and which deployments are in progress. | -| [Post-merge pipeline status](reviewing_and_managing_merge_requests.md#post-merge-pipeline-status) | When a merge request is merged, see the post-merge pipeline status of the branch the merge request was merged into. | -| [Resolve conflicts](resolve_conflicts.md) | GitLab can provide the option to resolve certain merge request conflicts in the GitLab UI. | -| [Revert changes](revert_changes.md) | Revert changes from any commit from within a merge request. | -| [Semi-linear history merge requests](reviewing_and_managing_merge_requests.md#semi-linear-history-merge-requests) | Enable semi-linear history merge requests as another security layer to guarantee the pipeline is passing in the target branch | -| [Suggest changes](../../discussions/index.md#suggest-changes) | Add suggestions to change the content of merge requests directly into merge request threads, and easily apply them to the codebase directly from the UI. | -| [Time Tracking](../time_tracking.md#time-tracking) | Add a time estimation and the time spent with that merge request. | -| [View changes between file versions](reviewing_and_managing_merge_requests.md#view-changes-between-file-versions) | View what will be changed when a merge request is merged. | -| [View group merge requests](reviewing_and_managing_merge_requests.md#view-merge-requests-for-all-projects-in-a-group) | List and view the merge requests within a group. | -| [View project merge requests](reviewing_and_managing_merge_requests.md#view-project-merge-requests) | List and view the merge requests within a project. | +See the features at your disposal to [review and manage merge requests](reviewing_and_managing_merge_requests.md). ## Testing and reports in merge requests diff --git a/doc/user/project/merge_requests/reviewing_and_managing_merge_requests.md b/doc/user/project/merge_requests/reviewing_and_managing_merge_requests.md index 97c16a9794d539e5c4860000939762b6feb914da..21c8b5c682bf5ae13161d19c2f2edfd48af8c929 100644 --- a/doc/user/project/merge_requests/reviewing_and_managing_merge_requests.md +++ b/doc/user/project/merge_requests/reviewing_and_managing_merge_requests.md @@ -13,7 +13,7 @@ which is then reviewed, and accepted (or rejected). View all the merge requests within a project by navigating to **Project > Merge Requests**. When you access your project's merge requests, GitLab will present them in a list, -and you can use the tabs available to quickly filter by open and closed. You can also [search and filter the results](../../search/index.md#issues-and-merge-requests-per-project). +and you can use the tabs available to quickly filter by open and closed. You can also [search and filter the results](../../search/index.md#filtering-issue-and-merge-request-lists).  @@ -21,7 +21,7 @@ and you can use the tabs available to quickly filter by open and closed. You can View merge requests in all projects in the group, including all projects of all descendant subgroups of the group. Navigate to **Group > Merge Requests** to view these merge requests. This view also has the open and closed merge requests tabs. -You can [search and filter the results](../../search/index.md#issues-and-merge-requests-per-group) from here. +You can [search and filter the results](../../search/index.md#filtering-issue-and-merge-request-lists) from here.  @@ -85,7 +85,7 @@ specific commit page. You can append `?w=1` while on the diffs page of a merge request to ignore any whitespace changes. -## Commenting on any file line in merge requests +## Perform inline code reviews > [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/issues/13950) in GitLab 11.5. @@ -94,20 +94,7 @@ in a Merge Request. To do so, click the **...** button in the gutter of the Merg  -## Live preview with Review Apps - -If you configured [Review Apps](https://about.gitlab.com/product/review-apps/) for your project, -you can preview the changes submitted to a feature-branch through a merge request -in a per-branch basis. No need to checkout the branch, install and preview locally; -all your changes will be available to preview by anyone with the Review Apps link. - -With GitLab's [Route Maps](../../../ci/review_apps/index.md#route-maps) set, the -merge request widget takes you directly to the pages changed, making it easier and -faster to preview proposed modifications. - -[Read more about Review Apps](../../../ci/review_apps/index.md). - -## Pipeline status in merge requests +## Pipeline status in merge requests widgets If you've set up [GitLab CI/CD](../../../ci/README.md) in your project, you will be able to see: @@ -135,6 +122,37 @@ be disabled. If the pipeline fails to deploy, the deployment info will be hidden For more information, [read about pipelines](../../../ci/pipelines.md). +### Merge when pipeline succeeds (MWPS) + +Set a merge request that looks ready to merge to [merge automatically when CI pipeline succeeds](merge_when_pipeline_succeeds.md). + +### Live preview with Review Apps + +If you configured [Review Apps](https://about.gitlab.com/product/review-apps/) for your project, +you can preview the changes submitted to a feature-branch through a merge request +in a per-branch basis. No need to checkout the branch, install and preview locally; +all your changes will be available to preview by anyone with the Review Apps link. + +With GitLab's [Route Maps](../../../ci/review_apps/index.md#route-maps) set, the +merge request widget takes you directly to the pages changed, making it easier and +faster to preview proposed modifications. + +[Read more about Review Apps](../../../ci/review_apps/index.md). + +## Associated features + +There is also a large number of features to associated to merge requests: + +| Feature | Description | +|-------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------| +| [Bulk editing merge requests](../../project/bulk_editing.md) | Update the attributes of multiple merge requests simultaneously. | +| [Cherry-pick changes](cherry_pick_changes.md) | Cherry-pick any commit in the UI by simply clicking the **Cherry-pick** button in a merged merge requests or a commit. | +| [Fast-forward merge requests](fast_forward_merge.md) | For a linear Git history and a way to accept merge requests without creating merge commits | +| [Find the merge request that introduced a change](versions.md) | When viewing the commit details page, GitLab will link to the merge request(s) containing that commit. | +| [Merge requests versions](versions.md) | Select and compare the different versions of merge request diffs | +| [Resolve conflicts](resolve_conflicts.md) | GitLab can provide the option to resolve certain merge request conflicts in the GitLab UI. | +| [Revert changes](revert_changes.md) | Revert changes from any commit from within a merge request. | + ## Troubleshooting Sometimes things don't go as expected in a merge request, here are some diff --git a/doc/user/project/milestones/index.md b/doc/user/project/milestones/index.md index eacc1fd12dcd0cdcc40f99269f0490ae2c22915f..82a63b07d6bb8e42f4e133588cd90d224eb541fa 100644 --- a/doc/user/project/milestones/index.md +++ b/doc/user/project/milestones/index.md @@ -20,7 +20,7 @@ Milestones can be used as Agile sprints so that you can track all issues and mer ## Milestones as releases -Similarily, milestones can be used as releases. To do so: +Similarly, milestones can be used as releases. To do so: 1. Set the milestone due date to represent the release date of your release and leave the milestone start date blank. 1. Set the milestone title to the version of your release, such as `Version 9.4`. diff --git a/doc/user/project/new_ci_build_permissions_model.md b/doc/user/project/new_ci_build_permissions_model.md index 5f3bb83df702b022dddc49c3bb83968ef69efb07..d1bb23396e487275d680d54c08fd89033b48401f 100644 --- a/doc/user/project/new_ci_build_permissions_model.md +++ b/doc/user/project/new_ci_build_permissions_model.md @@ -48,7 +48,7 @@ It is important to note that we have a few types of users: via another project's job. - **External users**: CI jobs created by [external users](../permissions.md#external-users-core-only) will have - access only to projects to which user has at least reporter access. This + access only to projects to which the user has at least Reporter access. This rules out accessing all internal projects by default. This allows us to make the CI and permission system more trustworthy. @@ -114,7 +114,7 @@ docker login -u gitlab-ci-token -p $CI_JOB_TOKEN registry.gitlab.com Using single token had multiple security implications: -- The token would be readable to anyone who had developer access to a project +- The token would be readable to anyone who had Developer access to a project that could run CI jobs, allowing the developer to register any specific Runner for that project. - The token would allow to access only the project's sources, forbidding from diff --git a/doc/user/project/operations/error_tracking.md b/doc/user/project/operations/error_tracking.md index 912d7fdbef564cd41eb303e49d981dfc8dc51c55..447d294bef820cf2e1474fa7dd93956e038fec67 100644 --- a/doc/user/project/operations/error_tracking.md +++ b/doc/user/project/operations/error_tracking.md @@ -53,12 +53,31 @@ From error list, users can navigate to the error details page by clicking the ti This page has: - A link to the Sentry issue. +- A link to the GitLab commit if the Sentry [release id/version](https://docs.sentry.io/workflow/releases/?platform=javascript#configure-sdk) on the Sentry Issue's first release matches a commit SHA in your GitLab hosted project. - Other details about the issue, including a full stack trace. -If the error has not been linked to an existing GitLab issue, a 'Create Issue' button will be visible: +By default, a **Create issue** button is displayed. Once you have used it to create an issue, the button is hidden. - + -If a link does exist, it will be shown in the details and the 'Create Issue' button will be hidden: +If a link does exist, it will be shown in the details and the 'Create issue' button will change to a 'View issue' button: - + + +## Taking Action on errors + +You can take action on Sentry Errors from within the GitLab UI. + +### Ignoring errors + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/39665) in GitLab 12.7. + +From within the [Error Details](#error-details) page you can ignore a Sentry error by simply clicking the **Ignore** button near the top of the page. + +Ignoring an error will prevent it from appearing in the [Error Tracking List](#error-tracking-list), and will silence notifications that were set up within Sentry. + +### Resolving errors + +From within the [Error Details](#error-details) page you can resolve a Sentry error by simply clicking the **Resolve** button near the top of the page. + +Marking an error as resolved indicates that the error has stopped firing events. If another event occurs, the error reverts to unresolved. diff --git a/doc/user/project/operations/feature_flags.md b/doc/user/project/operations/feature_flags.md index 723f9d699954e58925de4aa7bec5f175b26e7b77..4d372721d729aca9bf71e5022b9252acefc55410 100644 --- a/doc/user/project/operations/feature_flags.md +++ b/doc/user/project/operations/feature_flags.md @@ -1,6 +1,6 @@ # Feature Flags **(PREMIUM)** -> [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/11845) in GitLab 11.4. +> [Introduced](https://gitlab.com/gitlab-org/gitlab/merge_requests/7433) in GitLab 11.4. Feature flags allow you to ship a project in different flavors by dynamically toggling certain functionality. @@ -51,7 +51,10 @@ with ability to edit or remove them. To make a feature flag active or inactive, click the pencil icon to edit it, and toggle the status for each [spec](#define-environment-specs). - +The toggles next to each feature flag on the list page function as global shutoff switches. +If a toggle is off, that feature flag is disabled for every environment. + + ## Define environment specs diff --git a/doc/user/project/operations/img/error_details_v12_5.png b/doc/user/project/operations/img/error_details_v12_5.png index 5e3e63006400931825bb1f25c02f130f56755a57..f4866141948cefc02c94a6af7df0cd861e164564 100644 Binary files a/doc/user/project/operations/img/error_details_v12_5.png and b/doc/user/project/operations/img/error_details_v12_5.png differ diff --git a/doc/user/project/operations/img/error_details_v12_6.png b/doc/user/project/operations/img/error_details_v12_6.png index b9152bd2c11634230d78ccd13bda15f760e9c052..3194d8284d732b314221b1ffc50a484cad69306b 100644 Binary files a/doc/user/project/operations/img/error_details_v12_6.png and b/doc/user/project/operations/img/error_details_v12_6.png differ diff --git a/doc/user/project/operations/img/error_details_v12_7.png b/doc/user/project/operations/img/error_details_v12_7.png new file mode 100644 index 0000000000000000000000000000000000000000..1c7ace35e2a321b55c58293582af312193e26745 Binary files /dev/null and b/doc/user/project/operations/img/error_details_v12_7.png differ diff --git a/doc/user/project/operations/img/error_details_with_issue_v12_7.png b/doc/user/project/operations/img/error_details_with_issue_v12_7.png new file mode 100644 index 0000000000000000000000000000000000000000..aa846ee72201dbf8670567cfb92c103fc7226eb7 Binary files /dev/null and b/doc/user/project/operations/img/error_details_with_issue_v12_7.png differ diff --git a/doc/user/project/operations/img/feature_flags_list.png b/doc/user/project/operations/img/feature_flags_list.png deleted file mode 100644 index f3e85b9ce44c8dcaad5b6aafbed567d90b8b5f04..0000000000000000000000000000000000000000 Binary files a/doc/user/project/operations/img/feature_flags_list.png and /dev/null differ diff --git a/doc/user/project/operations/img/feature_flags_list_v12_7.png b/doc/user/project/operations/img/feature_flags_list_v12_7.png new file mode 100644 index 0000000000000000000000000000000000000000..a28a844b46df06a96e8642c9d81e16efd4b6aedb Binary files /dev/null and b/doc/user/project/operations/img/feature_flags_list_v12_7.png differ diff --git a/doc/user/project/pages/custom_domains_ssl_tls_certification/index.md b/doc/user/project/pages/custom_domains_ssl_tls_certification/index.md index 2f16606c5a8bd021d153e2e78b5992b0850efbe9..c427a5dcca8b77132d6364ffc0f91d7436a1c361 100644 --- a/doc/user/project/pages/custom_domains_ssl_tls_certification/index.md +++ b/doc/user/project/pages/custom_domains_ssl_tls_certification/index.md @@ -134,7 +134,7 @@ If you're using CloudFlare, check `domain.com` to your GitLab Pages site. Use an `A` record instead. > - **Do not** add any special chars after the default Pages domain. E.g., don't point `subdomain.domain.com` to - or `namespace.gitlab.io/`. Some domain hosting providers may request a trailling dot (`namespace.gitlab.io.`), though. + or `namespace.gitlab.io/`. Some domain hosting providers may request a trailing dot (`namespace.gitlab.io.`), though. > - GitLab Pages IP on GitLab.com [was changed](https://about.gitlab.com/blog/2017/03/06/we-are-changing-the-ip-of-gitlab-pages-on-gitlab-com/) in 2017. > - GitLab Pages IP on GitLab.com [has changed](https://about.gitlab.com/blog/2018/07/19/gcp-move-update/#gitlab-pages-and-custom-domains) from `52.167.214.135` to `35.185.44.232` in 2018. diff --git a/doc/user/project/pages/getting_started/new_or_existing_website.md b/doc/user/project/pages/getting_started/new_or_existing_website.md index 62b5fa331174cf064b4041ad0274eb96f93164b2..49a330ea2026061e8b6af8498591e545c19e61e7 100644 --- a/doc/user/project/pages/getting_started/new_or_existing_website.md +++ b/doc/user/project/pages/getting_started/new_or_existing_website.md @@ -17,7 +17,7 @@ To do so, follow the steps below. [Pages domain names](../getting_started_part_one.md#gitlab-pages-default-domain-names). 1. Clone it to your local computer, add your website files to your project, add, commit and push to GitLab. - Alternativelly, you can run `git init` in your local directory, + Alternatively, you can run `git init` in your local directory, add the remote URL: `git remote add origin git@gitlab.com:namespace/project-name.git`, then add, commit, and push to GitLab. diff --git a/doc/user/project/pages/getting_started_part_four.md b/doc/user/project/pages/getting_started_part_four.md index 27bd9da8d18004782117fdf32a1a3cbd976a9572..263b20ea224f16f714b881e68a332bb02850d3ae 100644 --- a/doc/user/project/pages/getting_started_part_four.md +++ b/doc/user/project/pages/getting_started_part_four.md @@ -1,5 +1,5 @@ --- -last_updated: 2019-06-04 +last_updated: 2020-01-06 type: reference, howto --- @@ -158,7 +158,7 @@ first thing GitLab Runner will look for in your `.gitlab-ci.yml` is a your container to run that script: ```yaml -image: ruby:2.3 +image: ruby:2.7 pages: script: @@ -170,9 +170,9 @@ pages: ``` In this case, you're telling the Runner to pull this image, which -contains Ruby 2.3 as part of its file system. When you don't specify +contains Ruby 2.7 as part of its file system. When you don't specify this image in your configuration, the Runner will use a default -image, which is Ruby 2.1. +image, which is Ruby 2.6. If your SSG needs [NodeJS](https://nodejs.org/) to build, you'll need to specify which image you want to use, and this image should @@ -198,7 +198,7 @@ To do that, we need to add another line to our CI, telling the Runner to only perform that _job_ called `pages` on the `master` branch `only`: ```yaml -image: ruby:2.3 +image: ruby:2.6 pages: script: @@ -221,7 +221,7 @@ and deploy. To specify which stage your _job_ is running, simply add another line to your CI: ```yaml -image: ruby:2.3 +image: ruby:2.6 pages: stage: deploy @@ -244,7 +244,7 @@ let's add another task (_job_) to our CI, telling it to test every push to other branches, `except` the `master` branch: ```yaml -image: ruby:2.3 +image: ruby:2.6 pages: stage: deploy @@ -294,7 +294,7 @@ every single _job_. In our example, notice that we run We don't need to repeat it: ```yaml -image: ruby:2.3 +image: ruby:2.6 before_script: - bundle install @@ -329,7 +329,7 @@ cache Jekyll dependencies in a `vendor` directory when we run `bundle install`: ```yaml -image: ruby:2.3 +image: ruby:2.6 cache: paths: diff --git a/doc/user/project/pages/introduction.md b/doc/user/project/pages/introduction.md index 01e1909f6d6566f8cf472258c769fce90d71d7cd..37b5e77c062b67bd24c2148b10312deef85c01f9 100644 --- a/doc/user/project/pages/introduction.md +++ b/doc/user/project/pages/introduction.md @@ -1,6 +1,6 @@ --- type: reference -last_updated: 2018-06-04 +last_updated: 2020-01-06 --- # Exploring GitLab Pages @@ -156,7 +156,7 @@ Below is a copy of `.gitlab-ci.yml` where the most significant line is the last one, specifying to execute everything in the `pages` branch: ``` -image: ruby:2.1 +image: ruby:2.6 pages: script: diff --git a/doc/user/project/quick_actions.md b/doc/user/project/quick_actions.md index 97ae429a33f95021bb768ccd7032bf78428f7c6d..a038dadd7e702d0763962308dac9a4eb4bde2c42 100644 --- a/doc/user/project/quick_actions.md +++ b/doc/user/project/quick_actions.md @@ -68,7 +68,8 @@ The following quick actions are applicable to descriptions, discussions and thre | `/remove_zoom` | ✓ | | | Remove Zoom meeting from this issue. ([Introduced in GitLab 12.4](https://gitlab.com/gitlab-org/gitlab/merge_requests/16609)) | | `/target_branch <local branch name>` | | ✓ | | Set target branch | | `/wip` | | ✓ | | Toggle the Work In Progress status | -| `/approve` | | ✓ | | Approve the merge request | +| `/approve` | | ✓ | | Approve the merge request **(STARTER)** | +| `/submit_review` | | ✓ | | Submit a pending review. ([Introduced in GitLab 12.7](https://gitlab.com/gitlab-org/gitlab/issues/8041)) **(PREMIUM)** | | `/merge` | | ✓ | | Merge (when pipeline succeeds) | | `/child_epic <epic>` | | | ✓ | Add child epic to `<epic>`. The `<epic>` value should be in the format of `&epic`, `group&epic`, or a URL to an epic. ([Introduced in GitLab 12.0](https://gitlab.com/gitlab-org/gitlab/issues/7330)) **(ULTIMATE)** | | `/remove_child_epic <epic>` | | | ✓ | Remove child epic from `<epic>`. The `<epic>` value should be in the format of `&epic`, `group&epic`, or a URL to an epic. ([Introduced in GitLab 12.0](https://gitlab.com/gitlab-org/gitlab/issues/7330)) **(ULTIMATE)** | diff --git a/doc/user/project/releases/img/custom_notifications_dropdown_v12_5.png b/doc/user/project/releases/img/custom_notifications_dropdown_v12_5.png new file mode 100644 index 0000000000000000000000000000000000000000..879599a71f592792ff01dd7bf35606b27274f53a Binary files /dev/null and b/doc/user/project/releases/img/custom_notifications_dropdown_v12_5.png differ diff --git a/doc/user/project/releases/img/custom_notifications_new_release_v12_4.png b/doc/user/project/releases/img/custom_notifications_new_release_v12_4.png deleted file mode 100644 index 6b4231d5804d765bee1092e0dfec7dfddf72a276..0000000000000000000000000000000000000000 Binary files a/doc/user/project/releases/img/custom_notifications_new_release_v12_4.png and /dev/null differ diff --git a/doc/user/project/releases/img/custom_notifications_new_release_v12_5.png b/doc/user/project/releases/img/custom_notifications_new_release_v12_5.png new file mode 100644 index 0000000000000000000000000000000000000000..d136aa710b29594f083d1fe8489e25c0382005f4 Binary files /dev/null and b/doc/user/project/releases/img/custom_notifications_new_release_v12_5.png differ diff --git a/doc/user/project/releases/img/edit_release_page_v12_6.png b/doc/user/project/releases/img/edit_release_page_v12_6.png index 8b9c502a2ef95b1f252e013ba7683b8f104ca53f..ae7641ac8a59283e395bf34d821ea354c37dee59 100644 Binary files a/doc/user/project/releases/img/edit_release_page_v12_6.png and b/doc/user/project/releases/img/edit_release_page_v12_6.png differ diff --git a/doc/user/project/releases/img/new_tag_12_5.png b/doc/user/project/releases/img/new_tag_12_5.png index 6137ad2ee568b0fa639ad33d8cba37a9cea5ba10..9a6145d71c70d3646eb50b5658abf9b866c454e8 100644 Binary files a/doc/user/project/releases/img/new_tag_12_5.png and b/doc/user/project/releases/img/new_tag_12_5.png differ diff --git a/doc/user/project/releases/img/release_edit_button_v12_6.png b/doc/user/project/releases/img/release_edit_button_v12_6.png index f60b0ecb1be88d34d600ec5667a9d43d31a3da6c..8cc080621cfbf572a2a9e468bde450342747627e 100644 Binary files a/doc/user/project/releases/img/release_edit_button_v12_6.png and b/doc/user/project/releases/img/release_edit_button_v12_6.png differ diff --git a/doc/user/project/releases/img/tags_12_5.png b/doc/user/project/releases/img/tags_12_5.png index 4c032f961253ac5b47d7f6cbb1bcc7029d35d2dc..c9673a5232db88febc174212912c5ae5f4dd9c54 100644 Binary files a/doc/user/project/releases/img/tags_12_5.png and b/doc/user/project/releases/img/tags_12_5.png differ diff --git a/doc/user/project/releases/index.md b/doc/user/project/releases/index.md index 58e028c89beeaca6f7b9579ac45f08cc79bf39af..c253210af469517a2e2e65c5241c96ad00c558a5 100644 --- a/doc/user/project/releases/index.md +++ b/doc/user/project/releases/index.md @@ -6,7 +6,7 @@ type: reference, howto > [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/issues/41766) in GitLab 11.7. -It's typical to create a [Git tag](../../../university/training/topics/tags.md) at +It is typical to create a [Git tag](../../../university/training/topics/tags.md) at the moment of release to introduce a checkpoint in your source code history, but in most cases your users will need compiled objects or other assets output by your CI system to use them, not just the raw source @@ -16,8 +16,12 @@ GitLab's **Releases** are a way to track deliverables in your project. Consider a snapshot in time of the source, build output, and other metadata or artifacts associated with a released version of your code. -At the moment, you can create Release entries via the [Releases API](../../../api/releases/index.md); -we recommend doing this as one of the last steps in your CI/CD release pipeline. +There are several ways to create a Release: + +- In the interface, when you create a new Git tag. +- In the interface, by adding a release note to an existing Git tag. +- Using the [Releases API](../../../api/releases/index.md): we recommend doing this as one of the last + steps in your CI/CD release pipeline. ## Getting started with Releases @@ -38,7 +42,7 @@ Release descriptions are unrelated. Description supports [Markdown](../../markdo You can currently add the following types of assets to each Release: -- [Source code](#source-code): state of the repo at the time of the Release +- [Source code](#source-code): state of the repository at the time of the Release - [Links](#links): to content such as built binaries or documentation GitLab will support more asset types in the future, including objects such @@ -117,11 +121,14 @@ of GitLab. You can be notified by email when a new Release is created for your project. -To subscribe to these notifications, navigate to your **Project**'s landing page, then click on the -bell icon. Choose **Custom** from the dropdown menu. The -following modal window will be then displayed, from which you can select **New release** to complete your subscription to new Releases notifications. +To subscribe to Release notifications: - +1. Navigate to your **Project**'s landing page. +1. Click the bell icon (**Notification setting**). +1. Select **Custom** from the dropdown menu. +  +1. Select **New release**. +  ## Add release notes to Git tags @@ -132,8 +139,9 @@ drag and drop files to it. Release notes are stored in GitLab's database. There are several ways to add release notes: - In the interface, when you create a new Git tag. -- In the interface, by adding a note to an existing Git tag. -- Using the GitLab API. +- In the interface, by adding a release note to an existing Git tag. +- Using the [Releases API](../../../api/releases/index.md): (we recommend doing this as one of the last + steps in your CI/CD release pipeline). To create a new tag, navigate to your project's **Repository > Tags** and click **New tag**. From there, you can fill the form with all the information @@ -154,11 +162,11 @@ parallel. This dataset will be a snapshot this new release (including linked milestones and issues) at moment of creation. Such collection of data will provide a chain of custody and facilitate processes like external audits, for example. -The gathered Evidence data is stored in the database upon creation of a new +The gathered evidence data is stored in the database upon creation of a new release as a JSON object. In GitLab 12.6, a link to -the Evidence data is provided for [each Release](#releases-list). +the evidence data is provided for [each Release](#releases-list). -Here's what this object can look like: +Here is what this object can look like: ```json { @@ -208,6 +216,22 @@ Here's what this object can look like: } ``` +### Enabling Release Evidence display **(CORE ONLY)** + +This feature comes with the `:release_evidence_collection` feature flag +disabled by default in GitLab self-managed instances. To turn it on, +ask a GitLab administrator with Rails console access to run the following +command: + +```ruby +Feature.enable(:release_evidence_collection) +``` + +NOTE: **Note:** +Please note that Release Evidence's data is collected regardless of this +feature flag, which only enables or disables the display of the data on the +Releases page. + <!-- ## Troubleshooting Include any troubleshooting steps that you can foresee. If you know beforehand what issues diff --git a/doc/user/project/repository/file_finder.md b/doc/user/project/repository/file_finder.md index 576001d4305a19bae10239eafb70f9291ec90b2b..91e6d2912d11980a0930a6ff03838f272f4ec77e 100644 --- a/doc/user/project/repository/file_finder.md +++ b/doc/user/project/repository/file_finder.md @@ -37,7 +37,7 @@ the `app/controllers/admin/deploy_keys_controller.rb` file. Using fuzzy search, we start by typing letters that get us closer to the file. -**Protip:** To narrow down your search, include `/` in your search terms. +**Tip:** To narrow down your search, include `/` in your search terms.  diff --git a/doc/user/project/repository/forking_workflow.md b/doc/user/project/repository/forking_workflow.md index 4cf0e458a53586d6739844e225a845dfa6aea6be..dddabfce4b34f1606757b5db3707d67bd5aa8efb 100644 --- a/doc/user/project/repository/forking_workflow.md +++ b/doc/user/project/repository/forking_workflow.md @@ -14,7 +14,7 @@ document more information about using branches to work together. Forking a project is in most cases a two-step process. -1. Click on the fork button located located in between the star and clone buttons on the project's home page. +1. Click on the fork button located in between the star and clone buttons on the project's home page.  @@ -41,6 +41,10 @@ CAUTION: **CAUTION:** From GitLab 12.6 onwards, if the [visibility of an upstream project is reduced](../../../public_access/public_access.md#reducing-visibility) in any way, the fork relationship with all its forks will be removed. +CAUTION: **Caution:** +[Repository mirroring](repository_mirroring.md) will help to keep your fork synced with the original repository. +Before approving a merge request you'll likely to be asked to sync before getting approval, hence automating it is recommend. + ## Merging upstream Once you are ready to send your code back to the main project, you need diff --git a/doc/user/project/repository/git_blame.md b/doc/user/project/repository/git_blame.md index 454b3f86df9c37680ac23b41c6ed450e39fad734..4b645e4c4bc2f8602dfe3bd4c1f47b00956e2e00 100644 --- a/doc/user/project/repository/git_blame.md +++ b/doc/user/project/repository/git_blame.md @@ -23,6 +23,11 @@ noted information: If you hover over a commit in the UI, you'll see a precise date and time for that commit. + + +To see earlier revisions of a specific line, click **View blame prior to this change** +until you've found the changes you're interested in viewing. + ## Associated `git` command If you're running `git` from the command line, the equivalent command is diff --git a/doc/user/project/repository/img/file_blame_button_v12_6.png b/doc/user/project/repository/img/file_blame_button_v12_6.png index b5a18e6726f1db4a234333e4d6fd1e0f638605d7..e7aa0d1ea3fac3f1315c582ff20598c1b31f72c6 100644 Binary files a/doc/user/project/repository/img/file_blame_button_v12_6.png and b/doc/user/project/repository/img/file_blame_button_v12_6.png differ diff --git a/doc/user/project/repository/img/file_blame_output_v12_6.png b/doc/user/project/repository/img/file_blame_output_v12_6.png index 4aca40353d5ac34fb879fe7767d3496a63c2e77b..c11864299176403bcb3b8751459a21ff643e5439 100644 Binary files a/doc/user/project/repository/img/file_blame_output_v12_6.png and b/doc/user/project/repository/img/file_blame_output_v12_6.png differ diff --git a/doc/user/project/repository/img/file_blame_previous_commit_v12_7.png b/doc/user/project/repository/img/file_blame_previous_commit_v12_7.png new file mode 100644 index 0000000000000000000000000000000000000000..2da9e28c84ffb4a5a3100f832b470b8fc7701f2d Binary files /dev/null and b/doc/user/project/repository/img/file_blame_previous_commit_v12_7.png differ diff --git a/doc/user/project/repository/img/file_history_button_v12_6.png b/doc/user/project/repository/img/file_history_button_v12_6.png index b5a18e6726f1db4a234333e4d6fd1e0f638605d7..e7aa0d1ea3fac3f1315c582ff20598c1b31f72c6 100644 Binary files a/doc/user/project/repository/img/file_history_button_v12_6.png and b/doc/user/project/repository/img/file_history_button_v12_6.png differ diff --git a/doc/user/project/repository/img/file_history_output_v12_6.png b/doc/user/project/repository/img/file_history_output_v12_6.png index 9e9855203af4ade4e68c1357c0754ff7194cc565..1a84f347a93a9455cc425e06b7bee9c056ea70d6 100644 Binary files a/doc/user/project/repository/img/file_history_output_v12_6.png and b/doc/user/project/repository/img/file_history_output_v12_6.png differ diff --git a/doc/user/project/repository/img/repository_mirroring_push_settings.png b/doc/user/project/repository/img/repository_mirroring_push_settings.png index 3c0eacaa2dfa9b9995a8a3cea5277aad12baf6d5..d055cc580c4f6c20a7d590d115bc42420872b9d9 100644 Binary files a/doc/user/project/repository/img/repository_mirroring_push_settings.png and b/doc/user/project/repository/img/repository_mirroring_push_settings.png differ diff --git a/doc/user/project/repository/img/web_editor_new_branch_from_issue_create_button_v12_6.png b/doc/user/project/repository/img/web_editor_new_branch_from_issue_create_button_v12_6.png index f40cc187b46992c1db62fdf185a3a064987fd235..1ef210240d49a3b1fb927917f0b21cdfd353ab25 100644 Binary files a/doc/user/project/repository/img/web_editor_new_branch_from_issue_create_button_v12_6.png and b/doc/user/project/repository/img/web_editor_new_branch_from_issue_create_button_v12_6.png differ diff --git a/doc/user/project/repository/img/web_editor_new_branch_from_issue_v_12_6.png b/doc/user/project/repository/img/web_editor_new_branch_from_issue_v_12_6.png index d5a92546d4083c9e5141e5aa7482ba39c09e5fa1..02cadd5de4cd2d177bed75511711014949654800 100644 Binary files a/doc/user/project/repository/img/web_editor_new_branch_from_issue_v_12_6.png and b/doc/user/project/repository/img/web_editor_new_branch_from_issue_v_12_6.png differ diff --git a/doc/user/project/repository/repository_mirroring.md b/doc/user/project/repository/repository_mirroring.md index 993c96d2ae4934f98a29790060b67785fef97e91..6da745a8772a6d16248fa32b091bcff21e4c43ac 100644 --- a/doc/user/project/repository/repository_mirroring.md +++ b/doc/user/project/repository/repository_mirroring.md @@ -22,7 +22,7 @@ There are two kinds of repository mirroring supported by GitLab: When the mirror repository is updated, all new branches, tags, and commits will be visible in the project's activity feed. -Users with at least [developer access](../../permissions.md) to the project can also force an +Users with at least [Developer access](../../permissions.md) to the project can also force an immediate update, unless: - The mirror is already being updated. diff --git a/doc/user/project/service_desk.md b/doc/user/project/service_desk.md index fad4af7102ac5514284b9b29a99f95fc09950e93..e9fce4740404c74fb21e0c81b9cbf6d67eb32977 100644 --- a/doc/user/project/service_desk.md +++ b/doc/user/project/service_desk.md @@ -48,43 +48,65 @@ users will only see the thread through email. ## Configuring Service Desk -> **Note:** -Service Desk is enabled on GitLab.com. If you're a -[Silver subscriber](https://about.gitlab.com/pricing/#gitlab-com), -you can skip the step 1 below; you only need to enable it per project. - -1. [Set up incoming email](../../administration/incoming_email.md#set-it-up) for the GitLab instance. This must - support [email sub-addressing](../../administration/incoming_email.md#email-sub-addressing). -1. Navigate to your project's **Settings > General** and scroll down to the **Service Desk** - section. -1. If you have the correct access and a Premium license, - you will see an option to set up Service Desk: - -  - -1. Checking that box will enable Service Desk for the project, and show a - unique email address to email issues to the project. These issues will be - [confidential](issues/confidential_issues.md), so they will only be visible to project members. - - **Warning**: this email address can be used by anyone to create an issue on - this project, whether or not they have access to your GitLab instance. - We recommend **putting this behind an alias** so that it can be changed if - needed, and **[enabling Akismet](../../integration/akismet.md)** on your GitLab instance to add spam - checking to this service. Unblocked email spam would result in many spam +NOTE: **Note:** +Service Desk is enabled on GitLab.com. If you're a [Silver subscriber](https://about.gitlab.com/pricing/#gitlab-com), +you can skip step 1 below; you only need to enable it per project. + +If you have the correct access and a Premium license, you have the option to set up Service Desk. +Follow these steps to do so: + +1. [Set up incoming email](../../administration/incoming_email.md#set-it-up) for the GitLab instance. + This must support [email sub-addressing](../../administration/incoming_email.md#email-sub-addressing). +1. Navigate to your project's **Settings > General** and locate the **Service Desk** section. +1. Enable the **Activate Service Desk** toggle. This reveals a unique email address to email issues + to the project. These issues will be [confidential](issues/confidential_issues.md), so they will + only be visible to project members. Note that in GitLab 11.7, we updated the generated email + address's format. The older format is still supported, however, allowing existing aliases or + contacts to continue working. + + DANGER: **Danger:** + This email address can be used by anyone to create an issue on this project, whether or not they + have access to your GitLab instance. We recommend **putting this behind an alias** so it can be + changed if needed, and **[enabling Akismet](../../integration/akismet.md)** on your GitLab + instance to add spam checking to this service. Unblocked email spam would result in many spam issues being created, and may disrupt your GitLab service. + If you have [templates](description_templates.md) in your repository, you can optionally select + one from the selector menu to append it to all Service Desk issues. +  - _In GitLab 11.7, we updated the format of the generated email address. - However the older format is still supported, allowing existing aliases - or contacts to continue working._ +Service Desk is now enabled for this project! You should be able to access it from your project +navigation's **Issues** menu. + + + +### Using customized email templates + + > [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/2460) in [GitLab Premium](https://about.gitlab.com/pricing/) 12.7. + +When a user submits a new issue using Service Desk, or when a new note is created on a Service Desk issue, an email is sent to the author. + +The body of these email messages can customized by using templates. To create a new customized template, +create a new Markdown (`.md`) file inside the `.gitlab/service_desk_templates/` +directory in your repository. Commit and push to your default branch. + +#### Thank you email -1. If you have [templates](description_templates.md) in your repository, then you can optionally - select one of these templates from the dropdown to append it to all Service Desk issues. +The **Thank you email** is the email sent to a user after they submit an issue. +The file name of the template has to be `thank_you.md`. +You can use `%{ISSUE_ID}` placeholder which will be replaced by an issue iid in the email and +`%{ISSUE_PATH}` placeholder which will be replaced by project path and the issue iid. +As the service desk issues are created as confidential (only project members can see them) +the response email doesn't provide the issue link. -1. Service Desk is now enabled for this project! You should be able to access it from your project's navigation **Issue submenu**: +#### New note email -  +The **New note email** is the email sent to a user when the issue they submitted has a new comment. +The file name of the template has to be `new_note.md`. +You can use `%{ISSUE_ID}` placeholder which will be replaced by an issue iid +in the email, `%{ISSUE_PATH}` placeholder which will be replaced by + project path and the issue iid and `%{NOTE_TEXT}` placeholder which will be replaced by the note text. ## Using Service Desk diff --git a/doc/user/project/settings/img/sharing_and_permissions_settings_v12_3.png b/doc/user/project/settings/img/sharing_and_permissions_settings_v12_3.png deleted file mode 100644 index cf7fdfe4ccea6adafd9e4e20299f5ebb227872af..0000000000000000000000000000000000000000 Binary files a/doc/user/project/settings/img/sharing_and_permissions_settings_v12_3.png and /dev/null differ diff --git a/doc/user/project/settings/import_export.md b/doc/user/project/settings/import_export.md index 9449ab6d10f7b82b3675d747183ce015237716c9..f5cfa256f5dd7b212bab0a7d30571243cde0954a 100644 --- a/doc/user/project/settings/import_export.md +++ b/doc/user/project/settings/import_export.md @@ -100,7 +100,7 @@ For more details on the specific data persisted in a project export, see the  1. Alternatively, you can come back to the project settings and download the - file from there, or generate a new export. Once the file available, the page + file from there, or generate a new export. Once the file is available, the page should show the **Download export** button:  diff --git a/doc/user/project/settings/index.md b/doc/user/project/settings/index.md index 2c7a24da8f928d302afa2587de3389198ce4c432..4e55f55dd280cbc3330190dfed3ca7d42a7708b4 100644 --- a/doc/user/project/settings/index.md +++ b/doc/user/project/settings/index.md @@ -22,31 +22,65 @@ The project description also partially supports [standard Markdown](../../markdo ### Sharing and permissions -Set up your project's access, [visibility](../../../public_access/public_access.md), and enable [Container Registry](../../packages/container_registry/index.md) for your projects: +For your repository, you can set up features such as public access, repository features, +documentation, access permissions, and more. To do so from your project, +go to **Settings** > **General**, and expand the **Visibility, project features, permissions** +section. - +You can now change the [Project visibility](../../../public_access/public_access.md). +If you set **Project Visibility** to public, you can limit access to some features +to **Only Project Members**. In addition, you can select the option to +[Allow users to request access](../members/index.md#project-membership-and-requesting-access). CAUTION: **Caution:** -[Reducing a project's visibility level](../../../public_access/public_access.md#reducing-visibility) -will remove the fork relationship between the project and any forked project. - -If Issues are disabled, or you can't access Issues because you're not a project member, then Labels and Milestones -links will be missing from the sidebar UI. - -You can still access them with direct links if you can access Merge Requests. This is deliberate, if you can see -Issues or Merge Requests, both of which use Labels and Milestones, then you shouldn't be denied access to Labels and Milestones pages. - -Project [Snippets](../../snippets.md) are enabled by default. +If you [reduce a project's visibility level](../../../public_access/public_access.md#reducing-visibility), +that action unlinks all forks of that project. + +Use the switches to enable or disable the following features: + +| Option | More access limit options | Description | +|:----------------------------------|:--------------------------|:-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| **Issues** | ✓ | Activates the GitLab issues tracker | +| **Repository** | ✓ | Enables [repository](../repository/) functionality | +| **Merge Requests** | ✓ | Enables [merge request](../merge_requests/) functionality; also see [Merge request settings](#merge-request-settings) | +| **Forks** | ✓ | Enables [forking](../index.md#fork-a-project) functionality | +| **Pipelines** | ✓ | Enables [CI/CD](../../../ci/README.md) functionality | +| **Container Registry** | | Activates a [registry](../../packages/container_registry/) for your docker images | +| **Git Large File Storage** | | Enables the use of [large files](../../../administration/lfs/manage_large_binaries_with_git_lfs.md#git-lfs) | +| **Packages** | | Supports configuration of a [package registry](../../../administration/packages/index.md#gitlab-package-registry-administration-premium-only) functionality | +| **Wiki** | ✓ | Enables a separate system for [documentation](../wiki/) | +| **Snippets** | ✓ | Enables [sharing of code and text](../../snippets.md) | +| **Pages** | ✓ | Allows you to [publish static websites](../pages/) | + +Some features depend on others: + +- If you disable the **Issues** option, GitLab also removes the following + features: + - **Issue Boards** + - [**Service Desk**](#service-desk-premium) **(PREMIUM)** + + NOTE: **Note:** + When the **Issues** option is disabled, you can still access **Milestones** + from merge requests. + +- Additionally, if you disable both **Issues** and **Merge Requests**, you will no + longer have access to: + - **Labels** + - **Milestones** + +- If you disable **Repository** functionality, GitLab also disables the following + features for your project: + + - **Merge Requests** + - **Pipelines** + - **Container Registry** + - **Git Large File Storage** + - **Packages** #### Disabling email notifications -You can disable all email notifications related to the project by selecting the -**Disable email notifications** checkbox. Only the project owner is allowed to change -this setting. - -### Issue settings - -Add an [issue description template](../description_templates.md#description-templates) to your project, so that every new issue will start with a custom template. +Project owners can disable all [email notifications](../../profile/notifications.md#gitlab-notification-emails) +related to the project by selecting the **Disable email notifications** checkbox. ### Merge request settings @@ -57,7 +91,8 @@ Set up your project's merge request settings: - Enable [merge request approvals](../merge_requests/merge_request_approvals.md). **(STARTER)** - Enable [merge only if pipeline succeeds](../merge_requests/merge_when_pipeline_succeeds.md). - Enable [merge only when all threads are resolved](../../discussions/index.md#only-allow-merge-requests-to-be-merged-if-all-threads-are-resolved). -- Enable [`delete source branch after merge` option by default](../merge_requests/creating_merge_requests.md#deleting-the-source-branch) +- Enable [`delete source branch after merge` option by default](../merge_requests/getting_started.md#deleting-the-source-branch) +- Configure [suggested changes commit messages](../../discussions/index.md#configure-the-commit-message-for-applied-suggestions)  diff --git a/doc/user/project/web_ide/img/commit_changes_v12_3.png b/doc/user/project/web_ide/img/commit_changes_v12_3.png index 0ee7da26d1aa04ccbb643bc708af84833691ebd2..e7dffbc76550a500fce365a56c3f095973e45fb9 100644 Binary files a/doc/user/project/web_ide/img/commit_changes_v12_3.png and b/doc/user/project/web_ide/img/commit_changes_v12_3.png differ diff --git a/doc/user/project/web_ide/img/review_changes_v12_3.png b/doc/user/project/web_ide/img/review_changes_v12_3.png deleted file mode 100644 index cbc96e3dcd9cb2e907151a6f7522d650017350e4..0000000000000000000000000000000000000000 Binary files a/doc/user/project/web_ide/img/review_changes_v12_3.png and /dev/null differ diff --git a/doc/user/project/web_ide/index.md b/doc/user/project/web_ide/index.md index ee96fca7fd190497f2bc43634bdce028742579ed..8f2314bf31fc352881713e502e9b19a494dc6cf6 100644 --- a/doc/user/project/web_ide/index.md +++ b/doc/user/project/web_ide/index.md @@ -46,12 +46,16 @@ Single file editing is based on the [Ace Editor](https://ace.c9.io). ## Stage and commit changes After making your changes, click the **Commit** button in the bottom left to -review the list of changed files. Click on each file to review the changes and -click the tick icon to stage the file. +review the list of changed files. If you're using GitLab 12.6 or older versions, +click on each file to review the changes and tick the item to stage a file. - +From [GitLab 12.7 onwards](https://gitlab.com/gitlab-org/gitlab/issues/33441), +all your files will be automatically staged. You still have the option to unstage +changes in case you want to submit them in multiple smaller commits. To unstage +a change, simply click the **Unstage** button when a staged file is open, or click +the undo icon next to **Staged changes** to unstage all changes. -Once you have staged some changes, you can add a commit message, commit the +Once you have finalized your changes, you can add a commit message, commit the staged changes and directly create a merge request. In case you don't have write access to the selected branch, you will see a warning, but still be able to create a new branch and start a merge request. diff --git a/doc/user/project/wiki/index.md b/doc/user/project/wiki/index.md index 687e185028902c77a65e06e1b9ee5600d4e606d9..80f5823c095ec5d63da9b27a8e829888221b78c2 100644 --- a/doc/user/project/wiki/index.md +++ b/doc/user/project/wiki/index.md @@ -38,7 +38,7 @@ automatically. For example, a title of `docs/my-page` will create a wiki page with a path `/wikis/docs/my-page`. Once you enter the page name, it's time to fill in its content. GitLab wikis -support Markdown, RDoc and AsciiDoc. For Markdown based pages, all the +support Markdown, RDoc, AsciiDoc and Org. For Markdown based pages, all the [Markdown features](../../markdown.md) are supported and for links there is some [wiki specific](../../markdown.md#wiki-specific-markdown) behavior. diff --git a/doc/user/search/img/group_issues_filter.png b/doc/user/search/img/group_issues_filter.png deleted file mode 100644 index 45eced79b9906e90d42814abba38777210ef98d0..0000000000000000000000000000000000000000 Binary files a/doc/user/search/img/group_issues_filter.png and /dev/null differ diff --git a/doc/user/search/img/issue_search_filter_v12_5.png b/doc/user/search/img/issue_search_filter_v12_5.png deleted file mode 100644 index 1e2dd3d98a3eb20a702e9e371bdf9ba34b5aee2c..0000000000000000000000000000000000000000 Binary files a/doc/user/search/img/issue_search_filter_v12_5.png and /dev/null differ diff --git a/doc/user/search/img/issue_search_filter_v12_7.png b/doc/user/search/img/issue_search_filter_v12_7.png new file mode 100644 index 0000000000000000000000000000000000000000..102a2e0859c9eb5e6bee05283e7fabd69b2f2bd5 Binary files /dev/null and b/doc/user/search/img/issue_search_filter_v12_7.png differ diff --git a/doc/user/search/index.md b/doc/user/search/index.md index bc31052b7586b243fe8765f8cf982245ad2d812d..d7ca43b11641ca74020a7cbdab7aa8548c0b8375 100644 --- a/doc/user/search/index.md +++ b/doc/user/search/index.md @@ -17,8 +17,8 @@ When you click **Issues**, you'll see the opened issues assigned to you straight You can search through **Open**, **Closed**, or **All** issues. -You can also filter the results using the search and filter field. This works in the same way as the ones found in the -per project pages described below. +You can also filter the results using the search and filter field, as described below in +[Filtering issue and merge request lists](#filtering-issue-and-merge-request-lists). ### Issues and MRs assigned to you or created by you @@ -27,19 +27,26 @@ on the search field on the top-right of your screen:  -### Issues and merge requests per project +### Filtering issue and merge request lists -If you want to search for issues present in a specific project, navigate to -a project's **Issues** tab, and click on the field **Search or filter results...**. It will -display a dropdown menu, from which you can add filters per author, assignee, milestone, -release, label, weight, confidentiality, and "my-reaction" (based on your emoji votes). -When done, press **Enter** on your keyboard to filter the issues. +Follow these steps to filter the **Issues** and **Merge Requests** list pages within projects and +groups: - +1. Click in the field **Search or filter results...**. +1. In the dropdown menu that appears, select the attribute you wish to filter by (for example, + author, assignee, milestone, and so on). +1. Select or type the operator to use for filtering the attribute. The following operators are + available: + - `=`: Is + - `!=`: Is not ([Introduced](https://gitlab.com/gitlab-org/gitlab/issues/18059) in GitLab 12.7) +1. Enter the text to filter the attribute by. +1. Repeat this process to filter by multiple attributes. Multiple attributes are joined by a logical + `AND`. -The same process is valid for merge requests. Navigate to your project's **Merge Requests** tab, -and click **Search or filter results...**. Merge requests can be filtered by author, assignee, -approver, milestone, release, label, "my-reaction", "work in progess" status, and target branch. +For example, filtering by Author `=` Jane and Milestone `!=` 12.6 filters for the issues where Jane +is the author and the milestone is not 12.6. + + ### Filtering by **None** / **Any** @@ -62,19 +69,10 @@ You can filter issues and merge requests by specific terms included in titles or - Limitation - For performance reasons, terms shorter than 3 chars are ignored. E.g.: searching issues for `included in titles` is same as `included titles` + - Search is limited to 4096 characters and 64 terms per query.  -### Issues and merge requests per group - -Similar to **Issues and merge requests per project**, you can also search for issues -within a group. Navigate to a group's **Issues** tab and query search results in -the same way as you do for projects. - - - -The same process is valid for merge requests. Navigate to your project's **Merge Requests** tab. - ## Search history You can view recent searches by clicking on the little arrow-clock icon, which is to the left of the search input. Click the search entry to run that search again. This feature is available for issues and merge requests. Searches are stored locally in your browser. diff --git a/lib/api/api.rb b/lib/api/api.rb index 56eccb036b6f12c51f37609a8976f0fa6df36eeb..1aee4fd30eed6b815d227b7d7747807f654bdc29 100644 --- a/lib/api/api.rb +++ b/lib/api/api.rb @@ -43,6 +43,14 @@ module API header['X-Content-Type-Options'] = 'nosniff' end + before do + Gitlab::ApplicationContext.push( + user: -> { current_user }, + project: -> { @project }, + namespace: -> { @group } + ) + end + # The locale is set to the current user's locale when `current_user` is loaded after { Gitlab::I18n.use_default_locale } @@ -96,6 +104,7 @@ module API # Keep in alphabetical order mount ::API::AccessRequests + mount ::API::Appearance mount ::API::Applications mount ::API::Avatar mount ::API::AwardEmoji @@ -108,6 +117,7 @@ module API mount ::API::DeployKeys mount ::API::Deployments mount ::API::Environments + mount ::API::ErrorTracking mount ::API::Events mount ::API::Features mount ::API::Files diff --git a/lib/api/appearance.rb b/lib/api/appearance.rb new file mode 100644 index 0000000000000000000000000000000000000000..a775102e87d8c8b6267d28ad5eee8ab99599aa04 --- /dev/null +++ b/lib/api/appearance.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module API + class Appearance < Grape::API + before { authenticated_as_admin! } + + helpers do + def current_appearance + @current_appearance ||= (::Appearance.current || ::Appearance.new) + end + end + + desc 'Get the current appearance' do + success Entities::Appearance + end + get "application/appearance" do + present current_appearance, with: Entities::Appearance + end + + desc 'Modify appearance' do + success Entities::Appearance + end + params do + optional :title, type: String, desc: 'Instance title on the sign in / sign up page' + optional :description, type: String, desc: 'Markdown text shown on the sign in / sign up page' + # TODO: remove rubocop disable - https://gitlab.com/gitlab-org/gitlab/issues/14960 + optional :logo, type: File, desc: 'Instance image used on the sign in / sign up page' # rubocop:disable Scalability/FileUploads + optional :header_logo, type: File, desc: 'Instance image used for the main navigation bar' # rubocop:disable Scalability/FileUploads + optional :favicon, type: File, desc: 'Instance favicon in .ico/.png format' # rubocop:disable Scalability/FileUploads + optional :new_project_guidelines, type: String, desc: 'Markmarkdown text shown on the new project page' + optional :header_message, type: String, desc: 'Message within the system header bar' + optional :footer_message, type: String, desc: 'Message within the system footer bar' + optional :message_background_color, type: String, desc: 'Background color for the system header / footer bar' + optional :message_font_color, type: String, desc: 'Font color for the system header / footer bar' + optional :email_header_and_footer_enabled, type: Boolean, desc: 'Add header and footer to all outgoing emails if enabled' + end + put "application/appearance" do + attrs = declared_params(include_missing: false) + + if current_appearance.update(attrs) + present current_appearance, with: Entities::Appearance + else + render_validation_error!(current_appearance) + end + end + end +end diff --git a/lib/api/applications.rb b/lib/api/applications.rb index 92717e045434b2740a58cc1e19a71137d097a6a9..4e9843e17e8d55c3a3cf6c0a7c5f77c564350b24 100644 --- a/lib/api/applications.rb +++ b/lib/api/applications.rb @@ -38,7 +38,7 @@ module API application = ApplicationsFinder.new(params).execute application.destroy - status 204 + no_content! end end end diff --git a/lib/api/award_emoji.rb b/lib/api/award_emoji.rb index 89b7e5c5e4b0067e313c22b46f6bab3b8759824f..7a815fa3dde6de587a917dcbf919e68c111b6360 100644 --- a/lib/api/award_emoji.rb +++ b/lib/api/award_emoji.rb @@ -27,7 +27,6 @@ module API ":id/#{awardable_string}/:#{awardable_id_string}/award_emoji", ":id/#{awardable_string}/:#{awardable_id_string}/notes/:note_id/award_emoji" ].each do |endpoint| - desc 'Get a list of project +awardable+ award emoji' do detail 'This feature was introduced in 8.9' success Entities::AwardEmoji diff --git a/lib/api/badges.rb b/lib/api/badges.rb index e987c24c707b73e319fe7de5e6ada9ae73f216bb..d2152fad07baa494f041b492ba5fa4765ed002bc 100644 --- a/lib/api/badges.rb +++ b/lib/api/badges.rb @@ -135,7 +135,6 @@ module API end destroy_conditionally!(badge) - body false end end end diff --git a/lib/api/branches.rb b/lib/api/branches.rb index ce3ee0d7e614be2bbc32c9b887760cc6b012208e..999bf1627c187ac7f9762a344bf5b515d5c00f59 100644 --- a/lib/api/branches.rb +++ b/lib/api/branches.rb @@ -57,7 +57,7 @@ module API requires :branch, type: String, desc: 'The name of the branch' end head do - user_project.repository.branch_exists?(params[:branch]) ? status(204) : status(404) + user_project.repository.branch_exists?(params[:branch]) ? no_content! : not_found! end get do branch = find_branch!(params[:branch]) diff --git a/lib/api/commit_statuses.rb b/lib/api/commit_statuses.rb index d108c811f4b41a1ea1dd38634d612951e3b975fe..6e26ee309f0774355f6412ae3c48412782fc319f 100644 --- a/lib/api/commit_statuses.rb +++ b/lib/api/commit_statuses.rb @@ -71,27 +71,27 @@ module API ref = params[:ref] ref ||= pipeline&.ref - ref ||= @project.repository.branch_names_contains(commit.sha).first + ref ||= user_project.repository.branch_names_contains(commit.sha).first not_found! 'References for commit' unless ref name = params[:name] || params[:context] || 'default' unless pipeline - pipeline = @project.ci_pipelines.create!( + pipeline = user_project.ci_pipelines.create!( source: :external, sha: commit.sha, ref: ref, user: current_user, - protected: @project.protected_for?(ref)) + protected: user_project.protected_for?(ref)) end status = GenericCommitStatus.running_or_pending.find_or_initialize_by( - project: @project, + project: user_project, pipeline: pipeline, name: name, ref: ref, user: current_user, - protected: @project.protected_for?(ref) + protected: user_project.protected_for?(ref) ) optional_attributes = @@ -117,7 +117,7 @@ module API render_api_error!('invalid state', 400) end - MergeRequest.where(source_project: @project, source_branch: ref) + MergeRequest.where(source_project: user_project, source_branch: ref) .update_all(head_pipeline_id: pipeline.id) if pipeline.latest? present status, with: Entities::CommitStatus diff --git a/lib/api/custom_attributes_endpoints.rb b/lib/api/custom_attributes_endpoints.rb index 2149e04451efe6aa70be0d8b4558b2177b8245ec..ef1264126f43fbc8bc1d101707c487aa9f1ab773 100644 --- a/lib/api/custom_attributes_endpoints.rb +++ b/lib/api/custom_attributes_endpoints.rb @@ -77,7 +77,7 @@ module API resource.custom_attributes.find_by!(key: params[:key]).destroy - status 204 + no_content! end # rubocop: enable CodeReuse/ActiveRecord end diff --git a/lib/api/deployments.rb b/lib/api/deployments.rb index 84d1d8a0aac642c92dfc6286dc210e1cade1c12c..487d4e37a562ba789b4fa8fc8029fab681cd488c 100644 --- a/lib/api/deployments.rb +++ b/lib/api/deployments.rb @@ -21,6 +21,14 @@ module API optional :sort, type: String, values: DeploymentsFinder::ALLOWED_SORT_DIRECTIONS, default: DeploymentsFinder::DEFAULT_SORT_DIRECTION, desc: 'Sort by asc (ascending) or desc (descending)' optional :updated_after, type: DateTime, desc: 'Return deployments updated after the specified date' optional :updated_before, type: DateTime, desc: 'Return deployments updated before the specified date' + optional :environment, + type: String, + desc: 'The name of the environment to filter deployments by' + + optional :status, + type: String, + values: Deployment.statuses.keys, + desc: 'The status to filter deployments by' end get ':id/deployments' do @@ -127,6 +135,26 @@ module API render_validation_error!(deployment) end end + + helpers Helpers::MergeRequestsHelpers + + desc 'Get all merge requests of a deployment' do + detail 'This feature was introduced in GitLab 12.7.' + success Entities::MergeRequestBasic + end + params do + requires :deployment_id, type: Integer, desc: 'The deployment ID' + use :merge_requests_base_params + end + + get ':id/deployments/:deployment_id/merge_requests' do + authorize! :read_deployment, user_project + + mr_params = declared_params.merge(deployment_id: params[:deployment_id]) + merge_requests = MergeRequestsFinder.new(current_user, mr_params).execute + + present merge_requests, { with: Entities::MergeRequestBasic, current_user: current_user } + end end end end diff --git a/lib/api/entities.rb b/lib/api/entities.rb index 76963777566e5942c919aef40070a1c8d864dc37..dfd0e676586dd1aeaa8a268e47c96ef59e349cd2 100644 --- a/lib/api/entities.rb +++ b/lib/api/entities.rb @@ -178,6 +178,15 @@ module API expose :only_protected_branches end + class ContainerExpirationPolicy < Grape::Entity + expose :cadence + expose :enabled + expose :keep_n + expose :older_than + expose :name_regex + expose :next_run_at + end + class ProjectImportStatus < ProjectIdentity expose :import_status @@ -276,6 +285,8 @@ module API expose :owner, using: Entities::UserBasic, unless: ->(project, options) { project.group } expose :resolve_outdated_diff_discussions expose :container_registry_enabled + expose :container_expiration_policy, using: Entities::ContainerExpirationPolicy, + if: -> (project, _) { project.container_expiration_policy } # Expose old field names with the new permissions methods to keep API compatible # TODO: remove in API v5, replaced by *_access_level @@ -324,6 +335,7 @@ module API expose :remove_source_branch_after_merge expose :printing_merge_request_link_enabled expose :merge_method + expose :suggestion_commit_message expose :statistics, using: 'API::Entities::ProjectStatistics', if: -> (project, options) { options[:statistics] && Ability.allowed?(options[:current_user], :read_statistics, project) } @@ -331,6 +343,7 @@ module API expose :auto_devops_deploy_strategy do |project, options| project.auto_devops.nil? ? 'continuous' : project.auto_devops.deploy_strategy end + expose :autoclose_referenced_issues # rubocop: disable CodeReuse/ActiveRecord def self.preload_relation(projects_relation, options = {}) @@ -340,6 +353,7 @@ module API # MR describing the solution: https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/20555 super(projects_relation).preload(:group) .preload(:ci_cd_settings) + .preload(:container_expiration_policy) .preload(:auto_devops) .preload(project_group_links: { group: :route }, fork_network: :root_project, @@ -400,6 +414,7 @@ module API expose :auto_devops_enabled expose :subgroup_creation_level_str, as: :subgroup_creation_level expose :emails_disabled + expose :mentions_disabled expose :lfs_enabled?, as: :lfs_enabled expose :avatar_url do |group, options| group.avatar_url(only_path: false) @@ -569,6 +584,20 @@ module API end end + class IssuableReferences < Grape::Entity + expose :short do |issuable| + issuable.to_reference + end + + expose :relative do |issuable, options| + issuable.to_reference(options[:group] || options[:project]) + end + + expose :full do |issuable| + issuable.to_reference(full: true) + end + end + class Diff < Grape::Entity expose :old_path, :new_path, :a_mode, :b_mode expose :new_file?, as: :new_file @@ -585,6 +614,7 @@ module API end class ProtectedBranch < Grape::Entity + expose :id expose :name expose :push_access_levels, using: Entities::ProtectedRefAccess expose :merge_access_levels, using: Entities::ProtectedRefAccess @@ -676,6 +706,10 @@ module API end end + expose :references, with: IssuableReferences do |issue| + issue + end + # Calculating the value of subscribed field triggers Markdown # processing. We can't do that for multiple issues / merge # requests in a single API request. @@ -761,9 +795,12 @@ module API expose :author, :assignees, using: Entities::UserBasic expose :source_project_id, :target_project_id - expose :labels do |merge_request| - # Avoids an N+1 query since labels are preloaded - merge_request.labels.map(&:title).sort + expose :labels do |merge_request, options| + if options[:with_labels_details] + ::API::Entities::LabelBasic.represent(merge_request.labels.sort_by(&:title)) + else + merge_request.labels.map(&:title).sort + end end expose :work_in_progress?, as: :work_in_progress expose :milestone, using: Entities::Milestone @@ -787,10 +824,16 @@ module API # Deprecated expose :allow_collaboration, as: :allow_maintainer_to_push, if: -> (merge_request, _) { merge_request.for_fork? } + # reference is deprecated in favour of references + # Introduced [Gitlab 12.6](https://gitlab.com/gitlab-org/gitlab/merge_requests/20354) expose :reference do |merge_request, options| merge_request.to_reference(options[:project]) end + expose :references, with: IssuableReferences do |merge_request| + merge_request + end + expose :web_url do |merge_request| Gitlab::UrlBuilder.build(merge_request) end @@ -883,6 +926,10 @@ module API expose :user, using: Entities::UserPublic end + class DeployKeyWithUser < SSHKeyWithUser + expose :deploy_keys_projects + end + class DeployKeysProject < Grape::Entity expose :deploy_key, merge: true, using: Entities::SSHKey expose :can_push @@ -1082,12 +1129,19 @@ module API end end - class ProjectService < Grape::Entity - expose :id, :title, :created_at, :updated_at, :active + class ProjectServiceBasic < Grape::Entity + expose :id, :title + expose :slug do |service| + service.to_param.dasherize + end + expose :created_at, :updated_at, :active expose :commit_events, :push_events, :issues_events, :confidential_issues_events expose :merge_requests_events, :tag_push_events, :note_events expose :confidential_note_events, :pipeline_events, :wiki_page_events - expose :job_events + expose :job_events, :comment_on_event_enabled + end + + class ProjectService < ProjectServiceBasic # Expose serialized properties expose :properties do |service, options| # TODO: Simplify as part of https://gitlab.com/gitlab-org/gitlab/issues/29404 @@ -1142,7 +1196,7 @@ module API end class LabelBasic < Grape::Entity - expose :id, :name, :color, :description, :text_color + expose :id, :name, :color, :description, :description_html, :text_color end class Label < LabelBasic @@ -1300,6 +1354,30 @@ module API expose :allow_local_requests_from_web_hooks_and_services, as: :allow_local_requests_from_hooks_and_services end + class Appearance < Grape::Entity + expose :title + expose :description + + expose :logo do |appearance, options| + appearance.logo.url + end + + expose :header_logo do |appearance, options| + appearance.header_logo.url + end + + expose :favicon do |appearance, options| + appearance.favicon.url + end + + expose :new_project_guidelines + expose :header_message + expose :footer_message + expose :message_background_color + expose :message_font_color + expose :email_header_and_footer_enabled + end + # deprecated old Release representation class TagRelease < Grape::Entity expose :tag, as: :tag_name diff --git a/lib/api/entities/error_tracking.rb b/lib/api/entities/error_tracking.rb new file mode 100644 index 0000000000000000000000000000000000000000..c762c274486dbdce775618c6a5c233a65480f225 --- /dev/null +++ b/lib/api/entities/error_tracking.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module API + module Entities + module ErrorTracking + class ProjectSetting < Grape::Entity + expose :enabled, as: :active + expose :project_name + expose :sentry_external_url + expose :api_url + end + end + end +end diff --git a/lib/api/error_tracking.rb b/lib/api/error_tracking.rb new file mode 100644 index 0000000000000000000000000000000000000000..f92f1326daa701ecd77e811acbea96f46898a77a --- /dev/null +++ b/lib/api/error_tracking.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module API + class ErrorTracking < Grape::API + before { authenticate! } + + params do + requires :id, type: String, desc: 'The ID of a project' + end + + resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do + desc 'Get error tracking settings for the project' do + detail 'This feature was introduced in GitLab 12.7.' + success Entities::ErrorTracking::ProjectSetting + end + + get ':id/error_tracking/settings' do + authorize! :admin_operations, user_project + + setting = user_project.error_tracking_setting + + not_found!('Error Tracking Setting') unless setting + + present setting, with: Entities::ErrorTracking::ProjectSetting + end + end + end +end diff --git a/lib/api/features.rb b/lib/api/features.rb index 4dc1834c64465dd5d9d7d2f1de593e886d39a34e..69b751e9bdbe19374731c292e7da1601aa6b39c6 100644 --- a/lib/api/features.rb +++ b/lib/api/features.rb @@ -74,7 +74,7 @@ module API delete ':name' do Feature.get(params[:name]).remove - status 204 + no_content! end end end diff --git a/lib/api/group_milestones.rb b/lib/api/group_milestones.rb index eae29f5b5ddd1c63ad0672657d262ed2c3e0e2e7..9e9f510128558e03b0d884e06b73aba9673ebc31 100644 --- a/lib/api/group_milestones.rb +++ b/lib/api/group_milestones.rb @@ -67,7 +67,7 @@ module API milestone = user_group.milestones.find(params[:milestone_id]) Milestones::DestroyService.new(user_group, current_user).execute(milestone) - status(204) + no_content! end desc 'Get all issues for a single group milestone' do diff --git a/lib/api/helpers.rb b/lib/api/helpers.rb index 37cb6d6a63907611446165e1e1fa890eff8ddc31..b2f5def4048ee3d7d26ee53fc5e9396b5b8af520 100644 --- a/lib/api/helpers.rb +++ b/lib/api/helpers.rb @@ -4,6 +4,7 @@ module API module Helpers include Gitlab::Utils include Helpers::Pagination + include Helpers::PaginationStrategies SUDO_HEADER = "HTTP_SUDO" GITLAB_SHARED_SECRET_HEADER = "Gitlab-Shared-Secret" @@ -30,6 +31,7 @@ module API check_unmodified_since!(last_updated) status 204 + body false if block_given? yield resource @@ -363,6 +365,10 @@ module API render_api_error!('204 No Content', 204) end + def created! + render_api_error!('201 Created', 201) + end + def accepted! render_api_error!('202 Accepted', 202) end diff --git a/lib/api/helpers/groups_helpers.rb b/lib/api/helpers/groups_helpers.rb index 2cc18acb7ec8159385221d2e1abf08680b17b66c..e0fea4c7c9609d62303b7fa1f44650d43bf24cf8 100644 --- a/lib/api/helpers/groups_helpers.rb +++ b/lib/api/helpers/groups_helpers.rb @@ -18,6 +18,7 @@ module API optional :auto_devops_enabled, type: Boolean, desc: 'Default to Auto DevOps pipeline for all projects within this group' optional :subgroup_creation_level, type: String, values: ::Gitlab::Access.subgroup_creation_string_values, desc: 'Allowed to create subgroups', as: :subgroup_creation_level_str optional :emails_disabled, type: Boolean, desc: 'Disable email notifications' + optional :mentions_disabled, type: Boolean, desc: 'Disable a group from getting mentioned' optional :lfs_enabled, type: Boolean, desc: 'Enable/disable LFS for the projects in this group' optional :request_access_enabled, type: Boolean, desc: 'Allow users to request member access' end diff --git a/lib/api/helpers/internal_helpers.rb b/lib/api/helpers/internal_helpers.rb index b03eb5ad440f293c03da7cf7d935a9844436f2ab..cc4a0d348a01b061c46f9fba8fb487e4320100c8 100644 --- a/lib/api/helpers/internal_helpers.rb +++ b/lib/api/helpers/internal_helpers.rb @@ -52,7 +52,7 @@ module API def log_user_activity(actor) commands = Gitlab::GitAccess::DOWNLOAD_COMMANDS - ::Users::ActivityService.new(actor, 'Git SSH').execute if commands.include?(params[:action]) + ::Users::ActivityService.new(actor).execute if commands.include?(params[:action]) end def merge_request_urls @@ -107,8 +107,10 @@ module API if params[:gl_repository] @project, @repo_type = Gitlab::GlRepository.parse(params[:gl_repository]) @redirected_path = nil - else + elsif params[:project] @project, @repo_type, @redirected_path = Gitlab::RepoPath.parse(params[:project]) + else + @project, @repo_type, @redirected_path = nil, nil, nil end end # rubocop:enable Gitlab/ModuleWithInstanceVariables @@ -120,21 +122,13 @@ module API end def gl_project_path - if wiki? - project.wiki.full_path - else - project.full_path - end + repository.full_path end # Return the repository depending on whether we want the wiki or the # regular repository def repository - if repo_type.wiki? - project.wiki.repository - else - project.repository - end + @repository ||= repo_type.repository_for(project) end # Return the Gitaly Address if it is enabled diff --git a/lib/api/helpers/members_helpers.rb b/lib/api/helpers/members_helpers.rb index 9e624903a62ac69fb6298498f1ad78cd95d67f3c..d06c59907b40f5be85cd2380948a83c38bb32d64 100644 --- a/lib/api/helpers/members_helpers.rb +++ b/lib/api/helpers/members_helpers.rb @@ -5,6 +5,11 @@ module API module Helpers module MembersHelpers + extend Grape::API::Helpers + + params :optional_filter_params_ee do + end + def find_source(source_type, id) public_send("find_#{source_type}!", id) # rubocop:disable GitlabSecurity/PublicSend end @@ -36,9 +41,15 @@ module API GroupMembersFinder.new(group).execute end + def create_member(current_user, user, source, params) + source.add_user(user, params[:access_level], current_user: current_user, expires_at: params[:expires_at]) + end + def present_members(members) present members, with: Entities::Member, current_user: current_user end end end end + +API::Helpers::MembersHelpers.prepend_if_ee('EE::API::Helpers::MembersHelpers') diff --git a/lib/api/helpers/merge_requests_helpers.rb b/lib/api/helpers/merge_requests_helpers.rb new file mode 100644 index 0000000000000000000000000000000000000000..0126d7a3756cc94bead5fc6536fde01cd8612139 --- /dev/null +++ b/lib/api/helpers/merge_requests_helpers.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +module API + module Helpers + module MergeRequestsHelpers + extend Grape::API::Helpers + include ::API::Helpers::CustomValidators + + params :merge_requests_base_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' + optional :order_by, + type: String, + values: %w[created_at updated_at], + default: 'created_at', + desc: 'Return merge requests ordered by `created_at` or `updated_at` fields.' + optional :sort, + type: String, + values: %w[asc desc], + default: 'desc', + desc: 'Return merge requests sorted in `asc` or `desc` order.' + optional :milestone, type: String, desc: 'Return merge requests for a specific milestone' + optional :labels, + type: Array[String], + coerce_with: Validations::Types::LabelsList.coerce, + desc: 'Comma-separated list of label names' + optional :with_labels_details, type: Boolean, desc: 'Return titles of labels and other details', default: false + optional :created_after, type: DateTime, desc: 'Return merge requests created after the specified time' + optional :created_before, type: DateTime, desc: 'Return merge requests created before the specified time' + optional :updated_after, type: DateTime, desc: 'Return merge requests updated after the specified time' + optional :updated_before, type: DateTime, desc: 'Return merge requests updated before the specified time' + optional :view, + type: String, + values: %w[simple], + desc: 'If simple, returns the `iid`, URL, title, description, and basic state of merge request' + optional :author_id, type: Integer, desc: 'Return merge requests which are authored by the user with the given ID' + optional :assignee_id, + types: [Integer, String], + integer_none_any: true, + desc: 'Return merge requests which are assigned to the user with the given ID' + optional :scope, + type: String, + values: %w[created-by-me assigned-to-me created_by_me assigned_to_me all], + desc: 'Return merge requests for the given scope: `created_by_me`, `assigned_to_me` or `all`' + optional :my_reaction_emoji, type: String, desc: 'Return issues reacted by the authenticated user by the given emoji' + optional :source_branch, type: String, desc: 'Return merge requests with the given source branch' + optional :source_project_id, type: Integer, desc: 'Return merge requests with the given source project id' + optional :target_branch, type: String, desc: 'Return merge requests with the given target branch' + optional :search, + type: String, + desc: 'Search merge requests for text present in the title, description, or any combination of these' + optional :in, type: String, desc: '`title`, `description`, or a string joining them with comma' + optional :wip, type: String, values: %w[yes no], desc: 'Search merge requests for WIP in the title' + end + + params :optional_scope_param do + optional :scope, + type: String, + values: %w[created-by-me assigned-to-me created_by_me assigned_to_me all], + default: 'created_by_me', + desc: 'Return merge requests for the given scope: `created_by_me`, `assigned_to_me` or `all`' + end + end + end +end diff --git a/lib/api/helpers/pagination.rb b/lib/api/helpers/pagination.rb index 1b63e450a122ce4c70d81c165b4498fa1765902c..a6ae9a87f98a8b8dcaaed57dc3b744449c63b242 100644 --- a/lib/api/helpers/pagination.rb +++ b/lib/api/helpers/pagination.rb @@ -3,34 +3,9 @@ module API module Helpers module Pagination - # This returns an ActiveRecord relation def paginate(relation) Gitlab::Pagination::OffsetPagination.new(self).paginate(relation) end - - # This applies pagination and executes the query - # It always returns an array instead of an ActiveRecord relation - def paginate_and_retrieve!(relation) - offset_or_keyset_pagination(relation).to_a - end - - private - - def offset_or_keyset_pagination(relation) - return paginate(relation) unless keyset_pagination_enabled? - - request_context = Gitlab::Pagination::Keyset::RequestContext.new(self) - - unless Gitlab::Pagination::Keyset.available?(request_context, relation) - return error!('Keyset pagination is not yet available for this type of request', 405) - end - - Gitlab::Pagination::Keyset.paginate(request_context, relation) - end - - def keyset_pagination_enabled? - params[:pagination] == 'keyset' && Feature.enabled?(:api_keyset_pagination, default_enabled: true) - end end end end diff --git a/lib/api/helpers/pagination_strategies.rb b/lib/api/helpers/pagination_strategies.rb new file mode 100644 index 0000000000000000000000000000000000000000..5f63635297aa96f7b5c11d374c6975d2d80a3a69 --- /dev/null +++ b/lib/api/helpers/pagination_strategies.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module API + module Helpers + module PaginationStrategies + def paginate_with_strategies(relation) + paginator = paginator(relation) + + yield(paginator.paginate(relation)).tap do |records, _| + paginator.finalize(records) + end + end + + def paginator(relation) + return Gitlab::Pagination::OffsetPagination.new(self) unless keyset_pagination_enabled? + + request_context = Gitlab::Pagination::Keyset::RequestContext.new(self) + + unless Gitlab::Pagination::Keyset.available?(request_context, relation) + return error!('Keyset pagination is not yet available for this type of request', 405) + end + + Gitlab::Pagination::Keyset::Pager.new(request_context) + end + + private + + def keyset_pagination_enabled? + params[:pagination] == 'keyset' && Feature.enabled?(:api_keyset_pagination, default_enabled: true) + end + end + end +end diff --git a/lib/api/helpers/projects_helpers.rb b/lib/api/helpers/projects_helpers.rb index 47b1f037eb8bdd3fd237a480bdb71ee1620a380f..6333e00daf5c72656264a436bbce72f12d9599ea 100644 --- a/lib/api/helpers/projects_helpers.rb +++ b/lib/api/helpers/projects_helpers.rb @@ -32,6 +32,9 @@ module API optional :resolve_outdated_diff_discussions, type: Boolean, desc: 'Automatically resolve merge request diffs discussions on lines changed with a push' optional :remove_source_branch_after_merge, type: Boolean, desc: 'Remove the source branch by default after merge' optional :container_registry_enabled, type: Boolean, desc: 'Flag indication if the container registry is enabled for that project' + optional :container_expiration_policy_attributes, type: Hash do + use :optional_container_expiration_policy_params + end optional :lfs_enabled, type: Boolean, desc: 'Flag indication if Git LFS is enabled for that project' optional :visibility, type: String, values: Gitlab::VisibilityLevel.string_values, desc: 'The visibility of the project.' optional :public_builds, type: Boolean, desc: 'Perform public builds' @@ -43,10 +46,12 @@ module API optional :avatar, type: File, desc: 'Avatar image for project' # rubocop:disable Scalability/FileUploads optional :printing_merge_request_link_enabled, type: Boolean, desc: 'Show link to create/view merge request when pushing from the command line' optional :merge_method, type: String, values: %w(ff rebase_merge merge), desc: 'The merge method used when merging merge requests' + optional :suggestion_commit_message, type: String, desc: 'The commit message used to apply merge request suggestions' optional :initialize_with_readme, type: Boolean, desc: "Initialize a project with a README.md" optional :ci_default_git_depth, type: Integer, desc: 'Default number of revisions for shallow cloning' optional :auto_devops_enabled, type: Boolean, desc: 'Flag indication if Auto DevOps is enabled' optional :auto_devops_deploy_strategy, type: String, values: %w(continuous manual timed_incremental), desc: 'Auto Deploy strategy' + optional :autoclose_referenced_issues, type: Boolean, desc: 'Flag indication if referenced issues auto-closing is enabled' end params :optional_project_params_ee do @@ -71,6 +76,14 @@ module API params :optional_update_params_ee do end + params :optional_container_expiration_policy_params do + optional :cadence, type: String, desc: 'Container expiration policy cadence for recurring job' + optional :keep_n, type: String, desc: 'Container expiration policy number of images to keep' + optional :older_than, type: String, desc: 'Container expiration policy remove images older than value' + optional :name_regex, type: String, desc: 'Container expiration policy regex for image removal' + optional :enabled, type: Boolean, desc: 'Flag indication if container expiration policy is enabled' + end + def self.update_params_at_least_one_of [ :auto_devops_enabled, @@ -83,8 +96,10 @@ module API :ci_config_path, :ci_default_git_depth, :container_registry_enabled, + :container_expiration_policy_attributes, :default_branch, :description, + :autoclose_referenced_issues, :issues_access_level, :lfs_enabled, :merge_requests_access_level, @@ -105,6 +120,7 @@ module API :visibility, :wiki_access_level, :avatar, + :suggestion_commit_message, # TODO: remove in API v5, replaced by *_access_level :issues_enabled, diff --git a/lib/api/helpers/services_helpers.rb b/lib/api/helpers/services_helpers.rb index b77be6edcf74b5ac74d1e38255327ae42f4e19a0..c02244c72026793710825da6c2ca7f5690b96295 100644 --- a/lib/api/helpers/services_helpers.rb +++ b/lib/api/helpers/services_helpers.rb @@ -365,6 +365,12 @@ module API name: :send_from_committer_email, type: Boolean, desc: 'Send from committer' + }, + { + required: false, + name: :branches_to_be_notified, + type: String, + desc: 'Branches for which notifications are to be sent' } ], 'external-wiki' => [ diff --git a/lib/api/internal/base.rb b/lib/api/internal/base.rb index 50142b8641eeda8be4a19728fe843f125bdd2063..d64de2bb465124b7eeed75093f092faa7bda7e6c 100644 --- a/lib/api/internal/base.rb +++ b/lib/api/internal/base.rb @@ -6,6 +6,13 @@ module API class Base < Grape::API before { authenticate_by_gitlab_shell_token! } + before do + Gitlab::ApplicationContext.push( + user: -> { actor&.user }, + project: -> { project } + ) + end + helpers ::API::Helpers::InternalHelpers UNKNOWN_CHECK_RESULT_ERROR = 'Unknown check result'.freeze @@ -205,7 +212,12 @@ module API status 200 response = Gitlab::InternalPostReceive::Response.new + + # Try to load the project and users so we have the application context + # available for logging before we schedule any jobs. user = actor.user + project + push_options = Gitlab::PushOptions.new(params[:push_options]) response.reference_counter_decreased = Gitlab::ReferenceCounter.new(params[:gl_repository]).decrease @@ -224,9 +236,9 @@ module API response.add_merge_request_urls(merge_request_urls) - # A user is not guaranteed to be returned; an orphaned write deploy + # Neither User nor Project are guaranteed to be returned; an orphaned write deploy # key could be used - if user + if user && project redirect_message = Gitlab::Checks::ProjectMoved.fetch_message(user.id, project.id) project_created_message = Gitlab::Checks::ProjectCreated.fetch_message(user.id, project.id) diff --git a/lib/api/issues.rb b/lib/api/issues.rb index 4208385a48d54ca24a16cd1bdba13b1d5bb9007b..4e21815fa35d0ff906540de82bf7949ff8e0e89c 100644 --- a/lib/api/issues.rb +++ b/lib/api/issues.rb @@ -48,7 +48,7 @@ module API end params :issues_params do - optional :with_labels_details, type: Boolean, desc: 'Return more label data than just lable title', default: false + optional :with_labels_details, type: Boolean, desc: 'Return titles of labels and other details', default: false optional :state, type: String, values: %w[opened closed all], default: 'all', desc: 'Return opened, closed, or all issues' optional :order_by, type: String, values: Helpers::IssuesHelpers.sort_options, default: 'created_at', @@ -122,16 +122,15 @@ module API use :issues_params end get ":id/issues" do - group = find_group!(params[:id]) - - issues = paginate(find_issues(group_id: group.id, include_subgroups: true)) + issues = paginate(find_issues(group_id: user_group.id, include_subgroups: true)) options = { with: Entities::Issue, with_labels_details: declared_params[:with_labels_details], current_user: current_user, issuable_metadata: issuable_meta_data(issues, 'Issue', current_user), - include_subscribed: false + include_subscribed: false, + group: user_group } present issues, options @@ -142,9 +141,7 @@ module API use :issues_stats_params end get ":id/issues_statistics" do - group = find_group!(params[:id]) - - present issues_statistics(group_id: group.id, include_subgroups: true), with: Grape::Presenters::Presenter + present issues_statistics(group_id: user_group.id, include_subgroups: true), with: Grape::Presenters::Presenter end end @@ -161,9 +158,7 @@ module API use :issues_params end get ":id/issues" do - project = find_project!(params[:id]) - - issues = paginate(find_issues(project_id: project.id)) + issues = paginate(find_issues(project_id: user_project.id)) options = { with: Entities::Issue, @@ -182,9 +177,7 @@ module API use :issues_stats_params end get ":id/issues_statistics" do - project = find_project!(params[:id]) - - present issues_statistics(project_id: project.id), with: Grape::Presenters::Presenter + present issues_statistics(project_id: user_project.id), with: Grape::Presenters::Presenter end desc 'Get a single project issue' do @@ -227,18 +220,22 @@ module API issue_params = convert_parameters_from_legacy_format(issue_params) - issue = ::Issues::CreateService.new(user_project, - current_user, - issue_params.merge(request: request, api: true)).execute - - if issue.spam? - render_api_error!({ error: 'Spam detected' }, 400) - end - - if issue.valid? - present issue, with: Entities::Issue, current_user: current_user, project: user_project - else - render_validation_error!(issue) + begin + issue = ::Issues::CreateService.new(user_project, + current_user, + issue_params.merge(request: request, api: true)).execute + + if issue.spam? + render_api_error!({ error: 'Spam detected' }, 400) + end + + if issue.valid? + present issue, with: Entities::Issue, current_user: current_user, project: user_project + else + render_validation_error!(issue) + end + rescue ::ActiveRecord::RecordNotUnique + render_api_error!('Duplicated issue', 409) end end diff --git a/lib/api/keys.rb b/lib/api/keys.rb index 8f837107192f001155ac613e2af20037d41b08dc..bec3dc9bd97bf6a7019133d69ecb735e4c5ff5e0 100644 --- a/lib/api/keys.rb +++ b/lib/api/keys.rb @@ -26,12 +26,15 @@ module API get do authenticated_with_can_read_all_resources! - finder_params = params.merge(key_type: 'ssh') - - key = KeysFinder.new(current_user, finder_params).execute + key = KeysFinder.new(current_user, params).execute not_found!('Key') unless key - present key, with: Entities::SSHKeyWithUser, current_user: current_user + + if key.type == "DeployKey" + present key, with: Entities::DeployKeyWithUser, current_user: current_user + else + present key, with: Entities::SSHKeyWithUser, current_user: current_user + end rescue KeysFinder::InvalidFingerprint render_api_error!('Failed to return the key', 400) end diff --git a/lib/api/members.rb b/lib/api/members.rb index 3526671e7f9e7577c56f64cc42176ce684dd172f..e4df2f341c6c8c1e3ceb1c6f439b747f2e2fbf32 100644 --- a/lib/api/members.rb +++ b/lib/api/members.rb @@ -19,6 +19,7 @@ module API params do optional :query, type: String, desc: 'A query string to search for members' optional :user_ids, type: Array[Integer], desc: 'Array of user ids to look up for membership' + use :optional_filter_params_ee use :pagination end @@ -100,12 +101,12 @@ module API user = User.find_by_id(params[:user_id]) not_found!('User') unless user - member = source.add_user(user, params[:access_level], current_user: current_user, expires_at: params[:expires_at]) + member = create_member(current_user, user, source, params) if !member not_allowed! # This currently can only be reached in EE elsif member.persisted? && member.valid? - present_members member + present_members(member) else render_validation_error!(member) end @@ -157,5 +158,3 @@ module API end end end - -API::Members.prepend_if_ee('EE::API::Members') diff --git a/lib/api/merge_requests.rb b/lib/api/merge_requests.rb index 794237f8032b9c57cfdd2207a4959b34ab1a0de9..bd857278ee5db96c2b1e91aeb4d2570dde9257df 100644 --- a/lib/api/merge_requests.rb +++ b/lib/api/merge_requests.rb @@ -7,6 +7,7 @@ module API before { authenticate_non_get! } helpers ::Gitlab::IssuableMetadata + helpers Helpers::MergeRequestsHelpers # EE::API::MergeRequests would override the following helpers helpers do @@ -68,12 +69,21 @@ module API end end - def not_automatically_mergeable?(merge_when_pipeline_succeeds, merge_request) - merge_when_pipeline_succeeds && !merge_request.head_pipeline_active? && !merge_request.actual_head_pipeline_success? + def automatically_mergeable?(merge_when_pipeline_succeeds, merge_request) + pipeline_active = merge_request.head_pipeline_active? || merge_request.actual_head_pipeline_active? + merge_when_pipeline_succeeds && merge_request.mergeable_state?(skip_ci_check: true) && pipeline_active + end + + def immediately_mergeable?(merge_when_pipeline_succeeds, merge_request) + if merge_when_pipeline_succeeds + merge_request.actual_head_pipeline_success? + else + merge_request.mergeable_state? + end end def serializer_options_for(merge_requests) - options = { with: Entities::MergeRequestBasic, current_user: current_user } + options = { with: Entities::MergeRequestBasic, current_user: current_user, with_labels_details: declared_params[:with_labels_details] } if params[:view] == 'simple' options[:with] = Entities::MergeRequestSimple @@ -98,32 +108,7 @@ module API 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' - optional :order_by, type: String, values: %w[created_at updated_at], default: 'created_at', - desc: 'Return merge requests ordered by `created_at` or `updated_at` fields.' - optional :sort, type: String, values: %w[asc desc], default: 'desc', - desc: 'Return merge requests sorted in `asc` or `desc` order.' - optional :milestone, type: String, desc: 'Return merge requests for a specific milestone' - optional :labels, type: Array[String], coerce_with: Validations::Types::LabelsList.coerce, desc: 'Comma-separated list of label names' - optional :created_after, type: DateTime, desc: 'Return merge requests created after the specified time' - optional :created_before, type: DateTime, desc: 'Return merge requests created before the specified time' - optional :updated_after, type: DateTime, desc: 'Return merge requests updated after the specified time' - optional :updated_before, type: DateTime, desc: 'Return merge requests updated before the specified time' - optional :view, type: String, values: %w[simple], desc: 'If simple, returns the `iid`, URL, title, description, and basic state of merge request' - optional :author_id, type: Integer, desc: 'Return merge requests which are authored by the user with the given ID' - optional :assignee_id, types: [Integer, String], integer_none_any: true, - desc: 'Return merge requests which are assigned to the user with the given ID' - optional :scope, type: String, values: %w[created-by-me assigned-to-me created_by_me assigned_to_me all], - desc: 'Return merge requests for the given scope: `created_by_me`, `assigned_to_me` or `all`' - optional :my_reaction_emoji, type: String, desc: 'Return issues reacted by the authenticated user by the given emoji' - optional :source_branch, type: String, desc: 'Return merge requests with the given source branch' - optional :source_project_id, type: Integer, desc: 'Return merge requests with the given source project id' - optional :target_branch, type: String, desc: 'Return merge requests with the given target branch' - optional :search, type: String, desc: 'Search merge requests for text present in the title, description, or any combination of these' - optional :in, type: String, desc: '`title`, `description`, or a string joining them with comma' - optional :wip, type: String, values: %w[yes no], desc: 'Search merge requests for WIP in the title' - + use :merge_requests_base_params use :optional_merge_requests_search_params use :pagination end @@ -135,8 +120,7 @@ module API end params do use :merge_requests_params - optional :scope, type: String, values: %w[created-by-me assigned-to-me created_by_me assigned_to_me all], default: 'created_by_me', - desc: 'Return merge requests for the given scope: `created_by_me`, `assigned_to_me` or `all`' + use :optional_scope_param end get do authenticate! unless params[:scope] == 'all' @@ -157,11 +141,9 @@ module API use :merge_requests_params end get ":id/merge_requests" do - group = find_group!(params[:id]) - - merge_requests = find_merge_requests(group_id: group.id, include_subgroups: true) + merge_requests = find_merge_requests(group_id: user_group.id, include_subgroups: true) - present merge_requests, serializer_options_for(merge_requests) + present merge_requests, serializer_options_for(merge_requests).merge(group: user_group) end end @@ -215,7 +197,7 @@ module API merge_requests = find_merge_requests(project_id: user_project.id) - options = serializer_options_for(merge_requests) + options = serializer_options_for(merge_requests).merge(project: user_project) options[:project] = user_project present merge_requests, options @@ -394,16 +376,18 @@ module API Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-foss/issues/42317') merge_request = find_project_merge_request(params[:merge_request_iid]) - merge_when_pipeline_succeeds = to_boolean(params[:merge_when_pipeline_succeeds]) - not_automatically_mergeable = not_automatically_mergeable?(merge_when_pipeline_succeeds, merge_request) # Merge request can not be merged # because user dont have permissions to push into target branch unauthorized! unless merge_request.can_be_merged_by?(current_user) - not_allowed! if !merge_request.mergeable_state?(skip_ci_check: merge_when_pipeline_succeeds) || not_automatically_mergeable + merge_when_pipeline_succeeds = to_boolean(params[:merge_when_pipeline_succeeds]) + automatically_mergeable = automatically_mergeable?(merge_when_pipeline_succeeds, merge_request) + immediately_mergeable = immediately_mergeable?(merge_when_pipeline_succeeds, merge_request) - render_api_error!('Branch cannot be merged', 406) unless merge_request.mergeable?(skip_ci_check: merge_when_pipeline_succeeds) + not_allowed! if !immediately_mergeable && !automatically_mergeable + + render_api_error!('Branch cannot be merged', 406) unless merge_request.mergeable?(skip_ci_check: automatically_mergeable) check_sha_param!(params, merge_request) @@ -416,13 +400,13 @@ module API sha: params[:sha] || merge_request.diff_head_sha ) - if merge_when_pipeline_succeeds - AutoMergeService.new(merge_request.target_project, current_user, merge_params) - .execute(merge_request, AutoMergeService::STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS) - else + if immediately_mergeable ::MergeRequests::MergeService .new(merge_request.target_project, current_user, merge_params) .execute(merge_request) + elsif automatically_mergeable + AutoMergeService.new(merge_request.target_project, current_user, merge_params) + .execute(merge_request, AutoMergeService::STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS) end present merge_request, with: Entities::MergeRequest, current_user: current_user, project: user_project @@ -455,12 +439,15 @@ module API desc 'Rebase the merge request against its target branch' do detail 'This feature was added in GitLab 11.6' end + params do + optional :skip_ci, type: Boolean, desc: 'Do not create CI pipeline' + 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) - merge_request.rebase_async(current_user.id) + merge_request.rebase_async(current_user.id, skip_ci: params[:skip_ci]) status :accepted present rebase_in_progress: merge_request.rebase_in_progress? diff --git a/lib/api/namespaces.rb b/lib/api/namespaces.rb index c51417d2889a5272f543e8c6a2b6d4e83d4ca150..e40a5dde7ce7df697b697adbbf9058268ca51365 100644 --- a/lib/api/namespaces.rb +++ b/lib/api/namespaces.rb @@ -32,6 +32,8 @@ module API get do namespaces = current_user.admin ? Namespace.all : current_user.namespaces + namespaces = namespaces.include_gitlab_subscription if Gitlab.ee? + namespaces = namespaces.search(params[:search]) if params[:search].present? options = { with: Entities::Namespace, current_user: current_user } diff --git a/lib/api/notes.rb b/lib/api/notes.rb index 89e4da5a42ea888006abe74b09f3815909e498e8..9575e8e9f365b35e11d58240a1ecec6dc4c61a55 100644 --- a/lib/api/notes.rb +++ b/lib/api/notes.rb @@ -24,6 +24,8 @@ module API desc: 'Return notes ordered by `created_at` or `updated_at` fields.' optional :sort, type: String, values: %w[asc desc], default: 'desc', desc: 'Return notes sorted in `asc` or `desc` order.' + optional :activity_filter, type: String, values: UserPreference::NOTES_FILTERS.stringify_keys.keys, default: 'all_notes', + desc: 'The type of notables which are returned.' use :pagination end # rubocop: disable CodeReuse/ActiveRecord @@ -35,7 +37,8 @@ module API # at the DB query level (which we cannot in that case), the current # page can have less elements than :per_page even if # there's more than one page. - raw_notes = noteable.notes.with_metadata.reorder(order_options_with_tie_breaker) + notes_filter = UserPreference::NOTES_FILTERS[params[:activity_filter].to_sym] + raw_notes = noteable.notes.with_metadata.with_notes_filter(notes_filter).reorder(order_options_with_tie_breaker) # paginate() only works with a relation. This could lead to a # mismatch between the pagination headers info and the actual notes diff --git a/lib/api/pages.rb b/lib/api/pages.rb index 39c8f1e6bdf170cb6d27c28ed1937fa829fd31f7..ee7fe669519aaf67cbaa0c13aa8146c92a5c067b 100644 --- a/lib/api/pages.rb +++ b/lib/api/pages.rb @@ -17,9 +17,9 @@ module API delete ':id/pages' do authorize! :remove_pages, user_project - status 204 - ::Pages::DeleteService.new(user_project, current_user).execute + + no_content! end end end diff --git a/lib/api/pages_domains.rb b/lib/api/pages_domains.rb index 9f8c1e4f91665ba5e36c8f5fba9355452a5846bf..4c3d2d131acf38274924dd00032f573f8d131ee4 100644 --- a/lib/api/pages_domains.rb +++ b/lib/api/pages_domains.rb @@ -148,8 +148,9 @@ module API delete ":id/pages/domains/:domain", requirements: PAGES_DOMAINS_ENDPOINT_REQUIREMENTS do authorize! :update_pages, user_project - status 204 pages_domain.destroy + + no_content! end end end diff --git a/lib/api/project_milestones.rb b/lib/api/project_milestones.rb index aebf7d5fae128321a5286ddf31adb307f908a379..8643854a655b1ddc0e41db778fea42494d13142a 100644 --- a/lib/api/project_milestones.rb +++ b/lib/api/project_milestones.rb @@ -69,7 +69,7 @@ module API milestone = user_project.milestones.find(params[:milestone_id]) Milestones::DestroyService.new(user_project, current_user).execute(milestone) - status(204) + no_content! end desc 'Get all issues for a single project milestone' do diff --git a/lib/api/project_snippets.rb b/lib/api/project_snippets.rb index b4545295d548d1faddc773318e53e55514410e0c..ecada843972310c18cd94d545821f154cc0f56a4 100644 --- a/lib/api/project_snippets.rb +++ b/lib/api/project_snippets.rb @@ -64,7 +64,8 @@ module API snippet_params = declared_params(include_missing: false).merge(request: request, api: true) snippet_params[:content] = snippet_params.delete(:code) if snippet_params[:code].present? - snippet = CreateSnippetService.new(user_project, current_user, snippet_params).execute + service_response = ::Snippets::CreateService.new(user_project, current_user, snippet_params).execute + snippet = service_response.payload[:snippet] render_spam_error! if snippet.spam? @@ -103,8 +104,8 @@ module API snippet_params[:content] = snippet_params.delete(:code) if snippet_params[:code].present? - UpdateSnippetService.new(user_project, current_user, snippet, - snippet_params).execute + service_response = ::Snippets::UpdateService.new(user_project, current_user, snippet_params).execute(snippet) + snippet = service_response.payload[:snippet] render_spam_error! if snippet.spam? @@ -127,7 +128,14 @@ module API authorize! :admin_project_snippet, snippet - destroy_conditionally!(snippet) + destroy_conditionally!(snippet) do |snippet| + service = ::Snippets::DestroyService.new(current_user, snippet) + response = service.execute + + if response.error? + render_api_error!({ error: response.message }, response.http_status) + end + end end # rubocop: enable CodeReuse/ActiveRecord diff --git a/lib/api/projects.rb b/lib/api/projects.rb index d1f99ea49ce1d14615ff2f304a7bdbd9cf5a9c57..2271131ced36c751b1e4bbd7b3972f9f55f2cc6c 100644 --- a/lib/api/projects.rb +++ b/lib/api/projects.rb @@ -90,18 +90,22 @@ module API def present_projects(projects, options = {}) projects = reorder_projects(projects) projects = apply_filters(projects) - projects = paginate(projects) - projects, options = with_custom_attributes(projects, options) - options = options.reverse_merge( - with: current_user ? Entities::ProjectWithAccess : Entities::BasicProjectDetails, - statistics: params[:statistics], - current_user: current_user, - license: false - ) - options[:with] = Entities::BasicProjectDetails if params[:simple] + records, options = paginate_with_strategies(projects) do |projects| + projects, options = with_custom_attributes(projects, options) + + options = options.reverse_merge( + with: current_user ? Entities::ProjectWithAccess : Entities::BasicProjectDetails, + statistics: params[:statistics], + current_user: current_user, + license: false + ) + options[:with] = Entities::BasicProjectDetails if params[:simple] + + [options[:with].prepare_relation(projects, options), options] + end - present options[:with].prepare_relation(projects, options), options + present records, options end def translate_params_for_compatibility(params) @@ -355,7 +359,7 @@ module API post ':id/unarchive' do authorize!(:archive_project, user_project) - ::Projects::UpdateService.new(@project, current_user, archived: false).execute + ::Projects::UpdateService.new(user_project, current_user, archived: false).execute present user_project, with: Entities::Project, current_user: current_user end @@ -443,7 +447,7 @@ module API ::Projects::UnlinkForkService.new(user_project, current_user).execute end - result ? status(204) : not_modified! + not_modified! unless result end desc 'Share the project with a group' do diff --git a/lib/api/releases.rb b/lib/api/releases.rb index 2df6050967ba078cdc13b97a2a186bd3f5e8f5c1..506d2b0f9859dff638fa2e737b2800f13acd46b2 100644 --- a/lib/api/releases.rb +++ b/lib/api/releases.rb @@ -66,6 +66,8 @@ module API .execute if result[:status] == :success + log_release_created_audit_event(result[:release]) + present result[:release], with: Entities::Release, current_user: current_user else render_api_error!(result[:message], result[:http_status]) @@ -91,6 +93,9 @@ module API .execute if result[:status] == :success + log_release_updated_audit_event + log_release_milestones_updated_audit_event if result[:milestones_updated] + present result[:release], with: Entities::Release, current_user: current_user else render_api_error!(result[:message], result[:http_status]) @@ -147,6 +152,20 @@ module API def release @release ||= user_project.releases.find_by_tag(params[:tag]) end + + def log_release_created_audit_event(release) + # This is a separate method so that EE can extend its behaviour + end + + def log_release_updated_audit_event + # This is a separate method so that EE can extend its behaviour + end + + def log_release_milestones_updated_audit_event + # This is a separate method so that EE can extend its behaviour + end end end end + +API::Releases.prepend_if_ee('EE::API::Releases') diff --git a/lib/api/remote_mirrors.rb b/lib/api/remote_mirrors.rb index 8a085517ce90342a348d66beb88db5828324d446..953139661333feaac13324b7822276b7adc292f8 100644 --- a/lib/api/remote_mirrors.rb +++ b/lib/api/remote_mirrors.rb @@ -7,6 +7,8 @@ module API before do # TODO: Remove flag: https://gitlab.com/gitlab-org/gitlab/issues/38121 not_found! unless Feature.enabled?(:remote_mirrors_api, user_project) + + unauthorized! unless can?(current_user, :admin_remote_mirror, user_project) end params do @@ -20,11 +22,35 @@ module API use :pagination end get ':id/remote_mirrors' do - unauthorized! unless can?(current_user, :admin_remote_mirror, user_project) - present paginate(user_project.remote_mirrors), with: Entities::RemoteMirror end + + desc 'Update the attributes of a single remote mirror' do + success Entities::RemoteMirror + end + params do + requires :mirror_id, type: String, desc: 'The ID of a remote mirror' + optional :enabled, type: Boolean, desc: 'Determines if the mirror is enabled' + optional :only_protected_branches, type: Boolean, desc: 'Determines if only protected branches are mirrored' + end + put ':id/remote_mirrors/:mirror_id' do + mirror = user_project.remote_mirrors.find(params[:mirror_id]) + + mirror_params = declared_params(include_missing: false) + mirror_params[:id] = mirror_params.delete(:mirror_id) + update_params = { remote_mirrors_attributes: mirror_params } + + result = ::Projects::UpdateService + .new(user_project, current_user, update_params) + .execute + + if result[:status] == :success + present mirror.reset, with: Entities::RemoteMirror + else + render_api_error!(result[:message], result[:http_status]) + end + end end end end diff --git a/lib/api/runner.rb b/lib/api/runner.rb index f383c541f8ac1d865707b32c2e5add8cc311d460..60cf9bf2c9c55e4a6deb827e97a921e9ed27588d 100644 --- a/lib/api/runner.rb +++ b/lib/api/runner.rb @@ -200,6 +200,10 @@ module API status 202 header 'Job-Status', job.status header 'Range', "0-#{stream_size}" + + if Feature.enabled?(:runner_job_trace_update_interval_header, default_enabled: true) + header 'X-GitLab-Trace-Update-Interval', job.trace.update_interval.to_s + end end desc 'Authorize artifacts uploading for job' do diff --git a/lib/api/services.rb b/lib/api/services.rb index 03c51f651722107b98a27d6348c1afc61d65c840..a3b5d2cc4b7b0c34ad4473614781e210aa7ee605 100644 --- a/lib/api/services.rb +++ b/lib/api/services.rb @@ -66,6 +66,15 @@ module API end end + desc 'Get all active project services' do + success Entities::ProjectServiceBasic + end + get ":id/services" do + services = user_project.services.active + + present services, with: Entities::ProjectServiceBasic + end + SERVICES.each do |service_slug, settings| desc "Set #{service_slug} service for project" params do diff --git a/lib/api/snippets.rb b/lib/api/snippets.rb index fd5422f2e2c78828ca8e2c6b7fcd71538dbe0f50..a7dab373b7f0cb3e5250a2d8051714bf306d08ca 100644 --- a/lib/api/snippets.rb +++ b/lib/api/snippets.rb @@ -75,7 +75,8 @@ module API end post do attrs = declared_params(include_missing: false).merge(request: request, api: true) - snippet = CreateSnippetService.new(nil, current_user, attrs).execute + service_response = ::Snippets::CreateService.new(nil, current_user, attrs).execute + snippet = service_response.payload[:snippet] render_spam_error! if snippet.spam? @@ -108,8 +109,8 @@ module API authorize! :update_personal_snippet, snippet attrs = declared_params(include_missing: false).merge(request: request, api: true) - - UpdateSnippetService.new(nil, current_user, snippet, attrs).execute + service_response = ::Snippets::UpdateService.new(nil, current_user, attrs).execute(snippet) + snippet = service_response.payload[:snippet] render_spam_error! if snippet.spam? @@ -133,7 +134,14 @@ module API authorize! :admin_personal_snippet, snippet - destroy_conditionally!(snippet) + destroy_conditionally!(snippet) do |snippet| + service = ::Snippets::DestroyService.new(current_user, snippet) + response = service.execute + + if response.error? + render_api_error!({ error: response.message }, response.http_status) + end + end end desc 'Get a raw snippet' do diff --git a/lib/api/users.rb b/lib/api/users.rb index b8c60f1969c465066f14381d5a160a4322cb3a8d..bf1fe4fc4a870230df5352d68544608bb895a2b4 100644 --- a/lib/api/users.rb +++ b/lib/api/users.rb @@ -346,8 +346,9 @@ module API key = user.gpg_keys.find_by(id: params[:key_id]) not_found!('GPG Key') unless key - status 204 key.destroy + + no_content! end # rubocop: enable CodeReuse/ActiveRecord @@ -760,8 +761,9 @@ module API key = current_user.gpg_keys.find_by(id: params[:key_id]) not_found!('GPG Key') unless key - status 204 key.destroy + + no_content! end # rubocop: enable CodeReuse/ActiveRecord diff --git a/lib/api/variables.rb b/lib/api/variables.rb index f022b9e665a3a1a10b6d83c92ad8089dd7228857..192b06b8a1b06ba09c813a57ae8520af07a00461 100644 --- a/lib/api/variables.rb +++ b/lib/api/variables.rb @@ -111,9 +111,10 @@ module API variable = user_project.variables.find_by(key: params[:key]) not_found!('Variable') unless variable - # Variables don't have any timestamp. Therfore, destroy unconditionally. - status 204 + # Variables don't have a timestamp. Therefore, destroy unconditionally. variable.destroy + + no_content! end # rubocop: enable CodeReuse/ActiveRecord end diff --git a/lib/api/wikis.rb b/lib/api/wikis.rb index c5a5488950d79a0430f92219703697d38051e9f3..a2146406690f3165bef73544658653c8de4390fd 100644 --- a/lib/api/wikis.rb +++ b/lib/api/wikis.rb @@ -26,7 +26,7 @@ module API type: String, values: ProjectWiki::MARKUPS.values.map(&:to_s), default: 'markdown', - desc: 'Format of a wiki page. Available formats are markdown, rdoc, and asciidoc' + desc: 'Format of a wiki page. Available formats are markdown, rdoc, asciidoc and org' end end @@ -107,8 +107,9 @@ module API delete ':id/wikis/:slug' do authorize! :admin_wiki, user_project - status 204 WikiPages::DestroyService.new(user_project, current_user).execute(wiki_page) + + no_content! end desc 'Upload an attachment to the wiki repository' do diff --git a/lib/banzai/filter/abstract_reference_filter.rb b/lib/banzai/filter/abstract_reference_filter.rb index ca1f61055b0d7c7832faccef3a67a83686ec9bca..5962403d4885a4795d919a0e9a05131163e2e02e 100644 --- a/lib/banzai/filter/abstract_reference_filter.rb +++ b/lib/banzai/filter/abstract_reference_filter.rb @@ -43,15 +43,46 @@ module Banzai # Returns a String replaced with the return of the block. def self.references_in(text, pattern = object_class.reference_pattern) text.gsub(pattern) do |match| - symbol = $~[object_sym] - if object_class.reference_valid?(symbol) - yield match, symbol.to_i, $~[:project], $~[:namespace], $~ + if ident = identifier($~) + yield match, ident, $~[:project], $~[:namespace], $~ else match end end end + def self.identifier(match_data) + symbol = symbol_from_match(match_data) + + parse_symbol(symbol, match_data) if object_class.reference_valid?(symbol) + end + + def identifier(match_data) + self.class.identifier(match_data) + end + + def self.symbol_from_match(match) + key = object_sym + match[key] if match.names.include?(key.to_s) + end + + # Transform a symbol extracted from the text to a meaningful value + # In most cases these will be integers, so we call #to_i by default + # + # This method has the contract that if a string `ref` refers to a + # record `record`, then `parse_symbol(ref) == record_identifier(record)`. + def self.parse_symbol(symbol, match_data) + symbol.to_i + end + + # We assume that most classes are identifying records by ID. + # + # This method has the contract that if a string `ref` refers to a + # record `record`, then `class.parse_symbol(ref) == record_identifier(record)`. + def record_identifier(record) + record.id + end + def object_class self.class.object_class end @@ -265,8 +296,10 @@ module Banzai @references_per[parent_type] ||= begin refs = Hash.new { |hash, key| hash[key] = Set.new } - - regex = Regexp.union(object_class.reference_pattern, object_class.link_reference_pattern) + regex = [ + object_class.link_reference_pattern, + object_class.reference_pattern + ].compact.reduce { |a, b| Regexp.union(a, b) } nodes.each do |node| node.to_html.scan(regex) do @@ -276,8 +309,9 @@ module Banzai full_group_path($~[:group]) end - symbol = $~[object_sym] - refs[path] << symbol if object_class.reference_valid?(symbol) + if ident = identifier($~) + refs[path] << ident + end end end diff --git a/lib/banzai/filter/base_relative_link_filter.rb b/lib/banzai/filter/base_relative_link_filter.rb new file mode 100644 index 0000000000000000000000000000000000000000..eca105ce9d9d77bfed45306c0a83a622697010c9 --- /dev/null +++ b/lib/banzai/filter/base_relative_link_filter.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require 'uri' + +module Banzai + module Filter + class BaseRelativeLinkFilter < HTML::Pipeline::Filter + include Gitlab::Utils::StrongMemoize + + protected + + def linkable_attributes + strong_memoize(:linkable_attributes) do + attrs = [] + + attrs += doc.search('a:not(.gfm)').map do |el| + el.attribute('href') + end + + attrs += doc.search('img:not(.gfm), video:not(.gfm), audio:not(.gfm)').flat_map do |el| + [el.attribute('src'), el.attribute('data-src')] + end + + attrs.reject do |attr| + attr.blank? || attr.value.start_with?('//') + end + end + end + + def relative_url_root + Gitlab.config.gitlab.relative_url_root.presence || '/' + end + + def project + context[:project] + end + + private + + def unescape_and_scrub_uri(uri) + Addressable::URI.unescape(uri).scrub + end + end + end +end diff --git a/lib/banzai/filter/commit_reference_filter.rb b/lib/banzai/filter/commit_reference_filter.rb index e1d7b36b9a2a904a3ce1786cf1487d1958ed95bb..3df003a88facf62bb69d41e44dd6e1c416fb5b33 100644 --- a/lib/banzai/filter/commit_reference_filter.rb +++ b/lib/banzai/filter/commit_reference_filter.rb @@ -37,6 +37,11 @@ module Banzai end end + # The default behaviour is `#to_i` - we just pass the hash through. + def self.parse_symbol(sha_hash, _match) + sha_hash + end + def url_for_object(commit, project) h = Gitlab::Routing.url_helpers @@ -65,10 +70,6 @@ module Banzai private - def record_identifier(record) - record.id - end - def parent_records(parent, ids) parent.commits_by(oids: ids.to_a) end diff --git a/lib/banzai/filter/plantuml_filter.rb b/lib/banzai/filter/plantuml_filter.rb index caba8955bac106566efce98aa5c2d5a06c44364f..1a75cd14b11513a3b6b3ecb82e722a2df8f75f7d 100644 --- a/lib/banzai/filter/plantuml_filter.rb +++ b/lib/banzai/filter/plantuml_filter.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true require "nokogiri" -require "asciidoctor-plantuml/plantuml" +require "asciidoctor_plantuml/plantuml" module Banzai module Filter diff --git a/lib/banzai/filter/relative_link_filter.rb b/lib/banzai/filter/repository_link_filter.rb similarity index 72% rename from lib/banzai/filter/relative_link_filter.rb rename to lib/banzai/filter/repository_link_filter.rb index 4f257189f8e6f799037ebfb62ebf647c8ff56e8e..14cd607cc50a269e7f323779b0a2ef94c21577ed 100644 --- a/lib/banzai/filter/relative_link_filter.rb +++ b/lib/banzai/filter/repository_link_filter.rb @@ -4,19 +4,17 @@ require 'uri' module Banzai module Filter - # HTML filter that "fixes" relative links to uploads or files in a repository. + # HTML filter that "fixes" relative links to files in a repository. # # Context options: # :commit - # :group # :current_user # :project # :project_wiki # :ref # :requested_path - class RelativeLinkFilter < HTML::Pipeline::Filter - include Gitlab::Utils::StrongMemoize - + # :system_note + class RepositoryLinkFilter < BaseRelativeLinkFilter def call return doc if context[:system_note] @@ -26,7 +24,9 @@ module Banzai load_uri_types linkable_attributes.each do |attr| - process_link_attr(attr) + if linkable_files? && repo_visible_to_user? + process_link_to_repository_attr(attr) + end end doc @@ -35,8 +35,8 @@ module Banzai protected def load_uri_types - return unless linkable_files? return unless linkable_attributes.present? + return unless linkable_files? return {} unless repository @uri_types = request_path.present? ? get_uri_types([request_path]) : {} @@ -57,24 +57,6 @@ module Banzai end end - def linkable_attributes - strong_memoize(:linkable_attributes) do - attrs = [] - - attrs += doc.search('a:not(.gfm)').map do |el| - el.attribute('href') - end - - attrs += doc.search('img, video, audio').flat_map do |el| - [el.attribute('src'), el.attribute('data-src')] - end - - attrs.reject do |attr| - attr.blank? || attr.value.start_with?('//') - end - end - end - def get_uri_types(paths) return {} if paths.empty? @@ -107,39 +89,6 @@ module Banzai rescue URI::Error, Addressable::URI::InvalidURIError end - def process_link_attr(html_attr) - if html_attr.value.start_with?('/uploads/') - process_link_to_upload_attr(html_attr) - elsif linkable_files? && repo_visible_to_user? - process_link_to_repository_attr(html_attr) - end - end - - def process_link_to_upload_attr(html_attr) - path_parts = [unescape_and_scrub_uri(html_attr.value)] - - if project - path_parts.unshift(relative_url_root, project.full_path) - elsif group - path_parts.unshift(relative_url_root, 'groups', group.full_path, '-') - else - path_parts.unshift(relative_url_root) - end - - begin - path = Addressable::URI.escape(File.join(*path_parts)) - rescue Addressable::URI::InvalidURIError - return - end - - html_attr.value = - if context[:only_path] - path - else - Addressable::URI.join(Gitlab.config.gitlab.base_url, path).to_s - end - end - def process_link_to_repository_attr(html_attr) uri = URI(html_attr.value) @@ -239,10 +188,6 @@ module Banzai @current_commit ||= context[:commit] || repository.commit(ref) end - def relative_url_root - Gitlab.config.gitlab.relative_url_root.presence || '/' - end - def repo_visible_to_user? project && Ability.allowed?(current_user, :download_code, project) end @@ -251,14 +196,6 @@ module Banzai context[:ref] || project.default_branch end - def group - context[:group] - end - - def project - context[:project] - end - def current_user context[:current_user] end @@ -266,12 +203,6 @@ module Banzai def repository @repository ||= project&.repository end - - private - - def unescape_and_scrub_uri(uri) - Addressable::URI.unescape(uri).scrub - end end end end diff --git a/lib/banzai/filter/upload_link_filter.rb b/lib/banzai/filter/upload_link_filter.rb new file mode 100644 index 0000000000000000000000000000000000000000..023c1288367b04fc14bbe7dbb31c0bb74c9d0c2b --- /dev/null +++ b/lib/banzai/filter/upload_link_filter.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require 'uri' + +module Banzai + module Filter + # HTML filter that "fixes" links to uploads. + # + # Context options: + # :group + # :only_path + # :project + # :system_note + class UploadLinkFilter < BaseRelativeLinkFilter + def call + return doc if context[:system_note] + + linkable_attributes.each do |attr| + process_link_to_upload_attr(attr) + end + + doc + end + + protected + + def process_link_to_upload_attr(html_attr) + return unless html_attr.value.start_with?('/uploads/') + + path_parts = [unescape_and_scrub_uri(html_attr.value)] + + if project + path_parts.unshift(relative_url_root, project.full_path) + elsif group + path_parts.unshift(relative_url_root, 'groups', group.full_path, '-') + else + path_parts.unshift(relative_url_root) + end + + begin + path = Addressable::URI.escape(File.join(*path_parts)) + rescue Addressable::URI::InvalidURIError + return + end + + html_attr.value = + if context[:only_path] + path + else + Addressable::URI.join(Gitlab.config.gitlab.base_url, path).to_s + end + + html_attr.parent.add_class('gfm') + end + + def group + context[:group] + end + end + end +end diff --git a/lib/banzai/pipeline/post_process_pipeline.rb b/lib/banzai/pipeline/post_process_pipeline.rb index fe629a23ff1936eddc3a5ee5c284230ae7147fa8..5e02d972614f4c56b89995511b6b11fc141a2a52 100644 --- a/lib/banzai/pipeline/post_process_pipeline.rb +++ b/lib/banzai/pipeline/post_process_pipeline.rb @@ -16,7 +16,10 @@ module Banzai [ Filter::ReferenceRedactorFilter, Filter::InlineMetricsRedactorFilter, - Filter::RelativeLinkFilter, + # UploadLinkFilter must come before RepositoryLinkFilter to + # prevent unnecessary Gitaly calls from being made. + Filter::UploadLinkFilter, + Filter::RepositoryLinkFilter, Filter::IssuableStateFilter, Filter::SuggestionFilter ] diff --git a/lib/banzai/pipeline/relative_link_pipeline.rb b/lib/banzai/pipeline/relative_link_pipeline.rb deleted file mode 100644 index 88651892acc42df2d22d2a0ce3d49edf76188749..0000000000000000000000000000000000000000 --- a/lib/banzai/pipeline/relative_link_pipeline.rb +++ /dev/null @@ -1,13 +0,0 @@ -# frozen_string_literal: true - -module Banzai - module Pipeline - class RelativeLinkPipeline < BasePipeline - def self.filters - FilterArray[ - Filter::RelativeLinkFilter - ] - end - end - end -end diff --git a/lib/banzai/reference_parser/base_parser.rb b/lib/banzai/reference_parser/base_parser.rb index 8419769085ae2b519991bfed2de3e8dd457c497f..9160c0e14cfefe663fe713fa619661412819790e 100644 --- a/lib/banzai/reference_parser/base_parser.rb +++ b/lib/banzai/reference_parser/base_parser.rb @@ -177,7 +177,7 @@ module Banzai collection.where(id: to_query).each { |row| cache[row.id] = row } end - cache.values_at(*ids).compact + ids.uniq.map { |id| cache[id] }.compact else collection.where(id: ids) end diff --git a/lib/feature.rb b/lib/feature.rb index 88b0d871c3adc2b5d8d3c7fe3c0e83114e3a3d3a..543512b1598dbeab9f32a11e655828c120dc6c9b 100644 --- a/lib/feature.rb +++ b/lib/feature.rb @@ -52,6 +52,10 @@ class Feature # use `default_enabled: true` to default the flag to being `enabled` # unless set explicitly. The default is `disabled` def enabled?(key, thing = nil, default_enabled: false) + # During setup the database does not exist yet. So we haven't stored a value + # for the feature yet and return the default. + return default_enabled unless Gitlab::Database.exists? + feature = Feature.get(key) # If we're not default enabling the flag or the feature has been set, always evaluate. diff --git a/lib/feature/gitaly.rb b/lib/feature/gitaly.rb index 625db1fce32b02ea0b71558543877fbd0f5fdfc7..2bd55c36a0323c05879bd39fd2e769389dccbf67 100644 --- a/lib/feature/gitaly.rb +++ b/lib/feature/gitaly.rb @@ -7,6 +7,7 @@ class Feature # Server feature flags should use '_' to separate words. SERVER_FEATURE_FLAGS = %w[ + cache_invalidator inforef_uploadpack_cache get_tag_messages_go filter_shas_with_signatures_go diff --git a/lib/gitlab.rb b/lib/gitlab.rb index 0e6db54eb46d51e6f751e807c4a1d4bd160942b3..f2bff51df386edc2a22fe2266f1f892116affb8f 100644 --- a/lib/gitlab.rb +++ b/lib/gitlab.rb @@ -100,8 +100,8 @@ module Gitlab end def self.process_name - return 'sidekiq' if Sidekiq.server? - return 'console' if defined?(Rails::Console) + return 'sidekiq' if Gitlab::Runtime.sidekiq? + return 'console' if Gitlab::Runtime.console? return 'test' if Rails.env.test? 'web' diff --git a/lib/gitlab/app_json_logger.rb b/lib/gitlab/app_json_logger.rb new file mode 100644 index 0000000000000000000000000000000000000000..e29b205e1bf87c2b0b42053040e91ea6f0749c28 --- /dev/null +++ b/lib/gitlab/app_json_logger.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Gitlab + class AppJsonLogger < Gitlab::JsonLogger + def self.file_name_noext + 'application_json' + end + end +end diff --git a/lib/gitlab/app_logger.rb b/lib/gitlab/app_logger.rb index 5edec8b3efee356927489887df2d618be4698bf0..3f5e9adf9250f791b735b1887c136a17736b2a73 100644 --- a/lib/gitlab/app_logger.rb +++ b/lib/gitlab/app_logger.rb @@ -1,13 +1,15 @@ # frozen_string_literal: true module Gitlab - class AppLogger < Gitlab::Logger - def self.file_name_noext - 'application' + class AppLogger < Gitlab::MultiDestinationLogger + LOGGERS = [Gitlab::AppTextLogger, Gitlab::AppJsonLogger].freeze + + def self.loggers + LOGGERS end - def format_message(severity, timestamp, progname, msg) - "#{timestamp.to_s(:long)}: #{msg}\n" + def self.primary_logger + Gitlab::AppTextLogger end end end diff --git a/lib/gitlab/app_text_logger.rb b/lib/gitlab/app_text_logger.rb new file mode 100644 index 0000000000000000000000000000000000000000..5b0439f43ad63ed87d818861da3b3b546090f9b1 --- /dev/null +++ b/lib/gitlab/app_text_logger.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Gitlab + class AppTextLogger < Gitlab::Logger + def self.file_name_noext + 'application' + end + + def format_message(severity, timestamp, progname, msg) + "#{timestamp.utc.iso8601(3)}: #{msg}\n" + end + end +end diff --git a/lib/gitlab/application_context.rb b/lib/gitlab/application_context.rb new file mode 100644 index 0000000000000000000000000000000000000000..71dbfea70e8e6b9039049caa9177a5d8a5e8d82e --- /dev/null +++ b/lib/gitlab/application_context.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +module Gitlab + # A GitLab-rails specific accessor for `Labkit::Logging::ApplicationContext` + class ApplicationContext + include Gitlab::Utils::LazyAttributes + + Attribute = Struct.new(:name, :type) + + APPLICATION_ATTRIBUTES = [ + Attribute.new(:project, Project), + Attribute.new(:namespace, Namespace), + Attribute.new(:user, User) + ].freeze + + def self.with_context(args, &block) + application_context = new(**args) + Labkit::Context.with_context(application_context.to_lazy_hash, &block) + end + + def self.push(args) + application_context = new(**args) + Labkit::Context.push(application_context.to_lazy_hash) + end + + def initialize(**args) + unknown_attributes = args.keys - APPLICATION_ATTRIBUTES.map(&:name) + raise ArgumentError, "#{unknown_attributes} are not known keys" if unknown_attributes.any? + + @set_values = args.keys + + assign_attributes(args) + end + + def to_lazy_hash + {}.tap do |hash| + hash[:user] = -> { username } if set_values.include?(:user) + hash[:project] = -> { project_path } if set_values.include?(:project) + hash[:root_namespace] = -> { root_namespace_path } if include_namespace? + end + end + + private + + attr_reader :set_values + + APPLICATION_ATTRIBUTES.each do |attr| + lazy_attr_reader attr.name, type: attr.type + end + + def assign_attributes(values) + values.slice(*APPLICATION_ATTRIBUTES.map(&:name)).each do |name, value| + instance_variable_set("@#{name}", value) + end + end + + def project_path + project&.full_path + end + + def username + user&.username + end + + def root_namespace_path + if namespace + namespace.full_path_components.first + else + project&.full_path_components&.first + end + end + + def include_namespace? + set_values.include?(:namespace) || set_values.include?(:project) + end + end +end diff --git a/lib/gitlab/asciidoc/include_processor.rb b/lib/gitlab/asciidoc/include_processor.rb index c6fbf540e9cc4fefc0c772fe042ce01df2e251c1..6e0b7ce60ba2fb1415e5657cde8948238761ba16 100644 --- a/lib/gitlab/asciidoc/include_processor.rb +++ b/lib/gitlab/asciidoc/include_processor.rb @@ -13,7 +13,7 @@ module Gitlab super(logger: Gitlab::AppLogger) @context = context - @repository = context[:project].try(:repository) + @repository = context[:repository] || context[:project].try(:repository) # Note: Asciidoctor calls #freeze on extensions, so we can't set new # instance variables after initialization. @@ -111,7 +111,7 @@ module Gitlab end def ref - context[:ref] || context[:project].default_branch + context[:ref] || repository&.root_ref end def requested_path diff --git a/lib/gitlab/auth.rb b/lib/gitlab/auth.rb index dfdba617cb695b4b047f2c40fd2f996b017a75f4..821c68dbedc1dc0650e02f29fae62b6bcd931e32 100644 --- a/lib/gitlab/auth.rb +++ b/lib/gitlab/auth.rb @@ -54,7 +54,7 @@ module Gitlab Gitlab::Auth::Result.new rate_limit!(rate_limiter, success: result.success?, login: login) - Gitlab::Auth::UniqueIpsLimiter.limit_user!(result.actor) + look_to_limit_user(result.actor) return result if result.success? || authenticate_using_internal_or_ldap_password? @@ -129,6 +129,10 @@ module Gitlab ::Ci::Build::CI_REGISTRY_USER == login end + def look_to_limit_user(actor) + Gitlab::Auth::UniqueIpsLimiter.limit_user!(actor) if actor.is_a?(User) + end + def authenticate_using_internal_or_ldap_password? Gitlab::CurrentSettings.password_authentication_enabled_for_git? || Gitlab::Auth::LDAP::Config.enabled? end diff --git a/lib/gitlab/auth/auth_finders.rb b/lib/gitlab/auth/auth_finders.rb index 33cbb070c2f28370e144c030d9dda116db1b0547..fe61d9fe8ca8cc01fb61c6286f591697a7c00631 100644 --- a/lib/gitlab/auth/auth_finders.rb +++ b/lib/gitlab/auth/auth_finders.rb @@ -25,9 +25,10 @@ module Gitlab PRIVATE_TOKEN_HEADER = 'HTTP_PRIVATE_TOKEN' PRIVATE_TOKEN_PARAM = :private_token - JOB_TOKEN_HEADER = "HTTP_JOB_TOKEN".freeze + JOB_TOKEN_HEADER = 'HTTP_JOB_TOKEN'.freeze JOB_TOKEN_PARAM = :job_token RUNNER_TOKEN_PARAM = :token + RUNNER_JOB_TOKEN_PARAM = :token # Check the Rails session for valid authentication details def find_user_from_warden @@ -57,11 +58,13 @@ module Gitlab def find_user_from_job_token return unless route_authentication_setting[:job_token_allowed] - token = (params[JOB_TOKEN_PARAM] || env[JOB_TOKEN_HEADER]).to_s - return unless token.present? + token = current_request.params[JOB_TOKEN_PARAM].presence || + current_request.params[RUNNER_JOB_TOKEN_PARAM].presence || + current_request.env[JOB_TOKEN_HEADER].presence + return unless token job = ::Ci::Build.find_by_token(token) - raise ::Gitlab::Auth::UnauthorizedError unless job + raise UnauthorizedError unless job @current_authenticated_job = job # rubocop:disable Gitlab/ModuleWithInstanceVariables diff --git a/lib/gitlab/auth/request_authenticator.rb b/lib/gitlab/auth/request_authenticator.rb index 34ccff588f402f0037ddac023e9adf9c8bc37f67..c6216fa9cad41960dd843c0c7c93fcc3212c859e 100644 --- a/lib/gitlab/auth/request_authenticator.rb +++ b/lib/gitlab/auth/request_authenticator.rb @@ -33,7 +33,8 @@ module Gitlab find_user_from_web_access_token(request_format) || find_user_from_feed_token(request_format) || find_user_from_static_object_token(request_format) || - find_user_from_basic_auth_job + find_user_from_basic_auth_job || + find_user_from_job_token rescue Gitlab::Auth::AuthenticationError nil end @@ -45,6 +46,14 @@ module Gitlab rescue Gitlab::Auth::AuthenticationError false end + + private + + def route_authentication_setting + @route_authentication_setting ||= { + job_token_allowed: api_request? + } + end end end end diff --git a/lib/gitlab/auth/unique_ips_limiter.rb b/lib/gitlab/auth/unique_ips_limiter.rb index 97e78ecf094c68ffb4cc43a963515545a2124f3a..74f7fdfc1808c452529c90dfde68319d873c44bf 100644 --- a/lib/gitlab/auth/unique_ips_limiter.rb +++ b/lib/gitlab/auth/unique_ips_limiter.rb @@ -8,7 +8,7 @@ module Gitlab class << self def limit_user_id!(user_id) if config.unique_ips_limit_enabled - ip = RequestContext.client_ip + ip = RequestContext.instance.client_ip unique_ips = update_and_return_ips_count(user_id, ip) raise TooManyIps.new(user_id, ip, unique_ips) if unique_ips > config.unique_ips_limit_per_user diff --git a/lib/gitlab/background_migration.rb b/lib/gitlab/background_migration.rb index 61e0a075018bb33fb35c0dcf4d50716621bda77a..ddd6b11eebbf8f9bd1d48590da3670447de91296 100644 --- a/lib/gitlab/background_migration.rb +++ b/lib/gitlab/background_migration.rb @@ -78,6 +78,20 @@ module Gitlab end def self.migration_class_for(class_name) + # We don't pass class name with Gitlab::BackgroundMigration:: prefix anymore + # but some jobs could be already spawned so we need to have some backward compatibility period. + # Can be removed since 13.x + full_class_name_prefix_regexp = /\A(::)?Gitlab::BackgroundMigration::/ + + if class_name.match(full_class_name_prefix_regexp) + Gitlab::ErrorTracking.track_and_raise_for_dev_exception( + StandardError.new("Full class name is used"), + class_name: class_name + ) + + class_name = class_name.sub(full_class_name_prefix_regexp, '') + end + const_get(class_name, false) end diff --git a/lib/gitlab/background_migration/activate_prometheus_services_for_shared_cluster_applications.rb b/lib/gitlab/background_migration/activate_prometheus_services_for_shared_cluster_applications.rb new file mode 100644 index 0000000000000000000000000000000000000000..19f5821d449af70214722091e28435c75fc0b703 --- /dev/null +++ b/lib/gitlab/background_migration/activate_prometheus_services_for_shared_cluster_applications.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module Gitlab + module BackgroundMigration + # Create missing PrometheusServices records or sets active attribute to true + # for all projects which belongs to cluster with Prometheus Application installed. + class ActivatePrometheusServicesForSharedClusterApplications + module Migratable + # Migration model namespace isolated from application code. + class PrometheusService < ActiveRecord::Base + self.inheritance_column = :_type_disabled + self.table_name = 'services' + + default_scope { where("services.type = 'PrometheusService'") } + + def self.for_project(project_id) + new( + project_id: project_id, + active: true, + properties: '{}', + type: 'PrometheusService', + template: false, + push_events: true, + issues_events: true, + merge_requests_events: true, + tag_push_events: true, + note_events: true, + category: 'monitoring', + default: false, + wiki_page_events: true, + pipeline_events: true, + confidential_issues_events: true, + commit_events: true, + job_events: true, + confidential_note_events: true, + deployment_events: false + ) + end + + def managed? + properties == '{}' + end + end + end + + def perform(project_id) + service = Migratable::PrometheusService.find_by(project_id: project_id) || Migratable::PrometheusService.for_project(project_id) + service.update!(active: true) if service.managed? + end + end + end +end diff --git a/lib/gitlab/background_migration/archive_legacy_traces.rb b/lib/gitlab/background_migration/archive_legacy_traces.rb index 3c26982729d269aefd251da2cdd05d17abebac98..79f38aed9f1206edc4f7bf1d0fc7b2fc79c9c5bd 100644 --- a/lib/gitlab/background_migration/archive_legacy_traces.rb +++ b/lib/gitlab/background_migration/archive_legacy_traces.rb @@ -11,7 +11,6 @@ module Gitlab # So we chose a way to use ::Ci::Build directly and we don't change the `archive!` method until 11.1 ::Ci::Build.finished.without_archived_trace .where(id: start_id..stop_id).find_each do |build| - build.trace.archive! rescue => e Rails.logger.error "Failed to archive live trace. id: #{build.id} message: #{e.message}" # rubocop:disable Gitlab/RailsLogger diff --git a/lib/gitlab/background_migration/backfill_version_data_from_gitaly.rb b/lib/gitlab/background_migration/backfill_version_data_from_gitaly.rb new file mode 100644 index 0000000000000000000000000000000000000000..83d60d2db1965b0159552e6212739cd5f0667b55 --- /dev/null +++ b/lib/gitlab/background_migration/backfill_version_data_from_gitaly.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Gitlab + module BackgroundMigration + # rubocop: disable Style/Documentation + class BackfillVersionDataFromGitaly + def perform(issue_id) + end + end + end +end + +Gitlab::BackgroundMigration::BackfillVersionDataFromGitaly.prepend_if_ee('EE::Gitlab::BackgroundMigration::BackfillVersionDataFromGitaly') diff --git a/lib/gitlab/background_migration/generate_gitlab_subscriptions.rb b/lib/gitlab/background_migration/generate_gitlab_subscriptions.rb new file mode 100644 index 0000000000000000000000000000000000000000..85bcf8558f22ac670a884d7b6e8482e4174e2579 --- /dev/null +++ b/lib/gitlab/background_migration/generate_gitlab_subscriptions.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Gitlab + module BackgroundMigration + # rubocop: disable Style/Documentation + class GenerateGitlabSubscriptions + def perform(start_id, stop_id) + end + end + end +end + +Gitlab::BackgroundMigration::GenerateGitlabSubscriptions.prepend_if_ee('EE::Gitlab::BackgroundMigration::GenerateGitlabSubscriptions') diff --git a/lib/gitlab/background_migration/migrate_approver_to_approval_rules.rb b/lib/gitlab/background_migration/migrate_approver_to_approval_rules.rb new file mode 100644 index 0000000000000000000000000000000000000000..27b984b45314cccc7ef47496458820a465cd75e6 --- /dev/null +++ b/lib/gitlab/background_migration/migrate_approver_to_approval_rules.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Gitlab + module BackgroundMigration + # rubocop: disable Style/Documentation + class MigrateApproverToApprovalRules + # @param target_type [String] class of target, either 'MergeRequest' or 'Project' + # @param target_id [Integer] id of target + def perform(target_type, target_id, sync_code_owner_rule: true) + end + end + end +end + +Gitlab::BackgroundMigration::MigrateApproverToApprovalRules.prepend_if_ee('EE::Gitlab::BackgroundMigration::MigrateApproverToApprovalRules') diff --git a/lib/gitlab/background_migration/migrate_approver_to_approval_rules_check_progress.rb b/lib/gitlab/background_migration/migrate_approver_to_approval_rules_check_progress.rb new file mode 100644 index 0000000000000000000000000000000000000000..053b7363286ee03b0e8280eb90aa6a69cecbf888 --- /dev/null +++ b/lib/gitlab/background_migration/migrate_approver_to_approval_rules_check_progress.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Gitlab + module BackgroundMigration + # rubocop: disable Style/Documentation + class MigrateApproverToApprovalRulesCheckProgress + def perform + end + end + end +end + +Gitlab::BackgroundMigration::MigrateApproverToApprovalRulesCheckProgress.prepend_if_ee('EE::Gitlab::BackgroundMigration::MigrateApproverToApprovalRulesCheckProgress') diff --git a/lib/gitlab/background_migration/migrate_approver_to_approval_rules_in_batch.rb b/lib/gitlab/background_migration/migrate_approver_to_approval_rules_in_batch.rb new file mode 100644 index 0000000000000000000000000000000000000000..130f97b09d7da95ea86667da416fdd8894597953 --- /dev/null +++ b/lib/gitlab/background_migration/migrate_approver_to_approval_rules_in_batch.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Gitlab + module BackgroundMigration + # rubocop: disable Style/Documentation + class MigrateApproverToApprovalRulesInBatch + def perform(start_id, end_id) + end + end + end +end + +Gitlab::BackgroundMigration::MigrateApproverToApprovalRulesInBatch.prepend_if_ee('EE::Gitlab::BackgroundMigration::MigrateApproverToApprovalRulesInBatch') diff --git a/lib/gitlab/background_migration/migrate_fingerprint_sha256_within_keys.rb b/lib/gitlab/background_migration/migrate_fingerprint_sha256_within_keys.rb new file mode 100644 index 0000000000000000000000000000000000000000..899f381e91119c3f373562ccc62e0be039265d50 --- /dev/null +++ b/lib/gitlab/background_migration/migrate_fingerprint_sha256_within_keys.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +module Gitlab + module BackgroundMigration + # This class is responsible to update all sha256 fingerprints within the keys table + class MigrateFingerprintSha256WithinKeys + # Temporary AR table for keys + class Key < ActiveRecord::Base + include EachBatch + + self.table_name = 'keys' + self.inheritance_column = :_type_disabled + end + + TEMP_TABLE = 'tmp_fingerprint_sha256_migration' + + def perform(start_id, stop_id) + ActiveRecord::Base.transaction do + execute(<<~SQL) + CREATE TEMPORARY TABLE #{TEMP_TABLE} + (id bigint primary key, fingerprint_sha256 bytea not null) + ON COMMIT DROP + SQL + + fingerprints = [] + Key.where(id: start_id..stop_id, fingerprint_sha256: nil).find_each do |regular_key| + if fingerprint = generate_ssh_public_key(regular_key.key) + bytea = ActiveRecord::Base.connection.escape_bytea(Base64.decode64(fingerprint)) + + fingerprints << { + id: regular_key.id, + fingerprint_sha256: bytea + } + end + end + + Gitlab::Database.bulk_insert(TEMP_TABLE, fingerprints) + + execute("ANALYZE #{TEMP_TABLE}") + + execute(<<~SQL) + UPDATE keys + SET fingerprint_sha256 = t.fingerprint_sha256 + FROM #{TEMP_TABLE} t + WHERE keys.id = t.id + SQL + end + end + + private + + def generate_ssh_public_key(regular_key) + Gitlab::SSHPublicKey.new(regular_key).fingerprint("SHA256")&.gsub("SHA256:", "") + end + + def execute(query) + ActiveRecord::Base.connection.execute(query) + end + end + end +end diff --git a/lib/gitlab/background_migration/migrate_issue_trackers_sensitive_data.rb b/lib/gitlab/background_migration/migrate_issue_trackers_sensitive_data.rb new file mode 100644 index 0000000000000000000000000000000000000000..14e14f2843993445ad8cfd4e63c2c9cb7aab595c --- /dev/null +++ b/lib/gitlab/background_migration/migrate_issue_trackers_sensitive_data.rb @@ -0,0 +1,146 @@ +# frozen_string_literal: true + +module Gitlab + module BackgroundMigration + # This migration takes all issue trackers + # and move data from properties to data field tables (jira_tracker_data and issue_tracker_data) + class MigrateIssueTrackersSensitiveData + delegate :select_all, :execute, :quote_string, to: :connection + + # we need to define this class and set fields encryption + class IssueTrackerData < ApplicationRecord + self.table_name = 'issue_tracker_data' + + def self.encryption_options + { + key: Settings.attr_encrypted_db_key_base_32, + encode: true, + mode: :per_attribute_iv, + algorithm: 'aes-256-gcm' + } + end + + attr_encrypted :project_url, encryption_options + attr_encrypted :issues_url, encryption_options + attr_encrypted :new_issue_url, encryption_options + end + + # we need to define this class and set fields encryption + class JiraTrackerData < ApplicationRecord + self.table_name = 'jira_tracker_data' + + def self.encryption_options + { + key: Settings.attr_encrypted_db_key_base_32, + encode: true, + mode: :per_attribute_iv, + algorithm: 'aes-256-gcm' + } + end + + attr_encrypted :url, encryption_options + attr_encrypted :api_url, encryption_options + attr_encrypted :username, encryption_options + attr_encrypted :password, encryption_options + end + + def perform(start_id, stop_id) + columns = 'id, properties, title, description, type' + batch_condition = "id >= #{start_id} AND id <= #{stop_id} AND category = 'issue_tracker' \ + AND properties IS NOT NULL AND properties != '{}' AND properties != ''" + + data_subselect = "SELECT 1 \ + FROM jira_tracker_data \ + WHERE jira_tracker_data.service_id = services.id \ + UNION SELECT 1 \ + FROM issue_tracker_data \ + WHERE issue_tracker_data.service_id = services.id" + + query = "SELECT #{columns} FROM services WHERE #{batch_condition} AND NOT EXISTS (#{data_subselect})" + + migrated_ids = [] + data_to_insert(query).each do |table, data| + service_ids = data.map { |s| s['service_id'] } + + next if service_ids.empty? + + migrated_ids += service_ids + Gitlab::Database.bulk_insert(table, data) + end + + return if migrated_ids.empty? + + move_title_description(migrated_ids) + end + + private + + def data_to_insert(query) + data = { 'jira_tracker_data' => [], 'issue_tracker_data' => [] } + select_all(query).each do |service| + begin + properties = JSON.parse(service['properties']) + rescue JSON::ParserError + logger.warn( + message: 'Properties data not parsed - invalid json', + service_id: service['id'], + properties: service['properties'] + ) + next + end + + if service['type'] == 'JiraService' + row = data_row(JiraTrackerData, jira_mapping(properties), service) + key = 'jira_tracker_data' + else + row = data_row(IssueTrackerData, issue_tracker_mapping(properties), service) + key = 'issue_tracker_data' + end + + data[key] << row if row + end + + data + end + + def data_row(klass, mapping, service) + base_params = { service_id: service['id'], created_at: Time.current, updated_at: Time.current } + klass.new(mapping).slice(*klass.column_names).compact.merge(base_params) + end + + def move_title_description(service_ids) + query = "UPDATE services SET \ + title = cast(properties as json)->>'title', \ + description = cast(properties as json)->>'description' \ + WHERE id IN (#{service_ids.join(',')}) AND title IS NULL AND description IS NULL" + + execute(query) + end + + def jira_mapping(properties) + { + url: properties['url'], + api_url: properties['api_url'], + username: properties['username'], + password: properties['password'] + } + end + + def issue_tracker_mapping(properties) + { + project_url: properties['project_url'], + issues_url: properties['issues_url'], + new_issue_url: properties['new_issue_url'] + } + end + + def connection + @connection ||= ActiveRecord::Base.connection + end + + def logger + @logger ||= Gitlab::BackgroundMigration::Logger.build + end + end + end +end diff --git a/lib/gitlab/background_migration/move_epic_issues_after_epics.rb b/lib/gitlab/background_migration/move_epic_issues_after_epics.rb new file mode 100644 index 0000000000000000000000000000000000000000..dc982e703d1244d673d7004a51ea6559b6581578 --- /dev/null +++ b/lib/gitlab/background_migration/move_epic_issues_after_epics.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Gitlab + module BackgroundMigration + # rubocop: disable Style/Documentation + class MoveEpicIssuesAfterEpics + def perform(start_id, stop_id) + end + end + end +end + +Gitlab::BackgroundMigration::MoveEpicIssuesAfterEpics.prepend_if_ee('EE::Gitlab::BackgroundMigration::MoveEpicIssuesAfterEpics') diff --git a/lib/gitlab/background_migration/populate_any_approval_rule_for_merge_requests.rb b/lib/gitlab/background_migration/populate_any_approval_rule_for_merge_requests.rb new file mode 100644 index 0000000000000000000000000000000000000000..c3c0db2495c60540cfa2f429cb2ccf4e90e76862 --- /dev/null +++ b/lib/gitlab/background_migration/populate_any_approval_rule_for_merge_requests.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Gitlab + module BackgroundMigration + # This background migration creates any approver rule records according + # to the given merge request IDs range. A _single_ INSERT is issued for the given range. + class PopulateAnyApprovalRuleForMergeRequests + def perform(from_id, to_id) + end + end + end +end + +Gitlab::BackgroundMigration::PopulateAnyApprovalRuleForMergeRequests.prepend_if_ee('EE::Gitlab::BackgroundMigration::PopulateAnyApprovalRuleForMergeRequests') diff --git a/lib/gitlab/background_migration/populate_any_approval_rule_for_projects.rb b/lib/gitlab/background_migration/populate_any_approval_rule_for_projects.rb new file mode 100644 index 0000000000000000000000000000000000000000..2243c7531c065ed7335f4b72c4e2d1da203d5609 --- /dev/null +++ b/lib/gitlab/background_migration/populate_any_approval_rule_for_projects.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Gitlab + module BackgroundMigration + # This background migration creates any approver rule records according + # to the given project IDs range. A _single_ INSERT is issued for the given range. + class PopulateAnyApprovalRuleForProjects + def perform(from_id, to_id) + end + end + end +end + +Gitlab::BackgroundMigration::PopulateAnyApprovalRuleForProjects.prepend_if_ee('EE::Gitlab::BackgroundMigration::PopulateAnyApprovalRuleForProjects') diff --git a/lib/gitlab/background_migration/prune_orphaned_geo_events.rb b/lib/gitlab/background_migration/prune_orphaned_geo_events.rb new file mode 100644 index 0000000000000000000000000000000000000000..8b16db8be35a8621b3b136a9832215c49c579d85 --- /dev/null +++ b/lib/gitlab/background_migration/prune_orphaned_geo_events.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true +# +# rubocop:disable Style/Documentation + +# This job is added to fix https://gitlab.com/gitlab-org/gitlab/issues/30229 +# It's not used anywhere else. +# Can be removed in GitLab 13.* +module Gitlab + module BackgroundMigration + class PruneOrphanedGeoEvents + def perform(table_name) + end + end + end +end + +Gitlab::BackgroundMigration::PruneOrphanedGeoEvents.prepend_if_ee('EE::Gitlab::BackgroundMigration::PruneOrphanedGeoEvents') diff --git a/lib/gitlab/background_migration/update_authorized_keys_file_since.rb b/lib/gitlab/background_migration/update_authorized_keys_file_since.rb new file mode 100644 index 0000000000000000000000000000000000000000..dd80d4bab1a94e672dcf0f2d0dc02c7904e393b5 --- /dev/null +++ b/lib/gitlab/background_migration/update_authorized_keys_file_since.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Gitlab + module BackgroundMigration + # rubocop: disable Style/Documentation + class UpdateAuthorizedKeysFileSince + def perform(cutoff_datetime) + end + end + end +end + +Gitlab::BackgroundMigration::UpdateAuthorizedKeysFileSince.prepend_if_ee('EE::Gitlab::BackgroundMigration::UpdateAuthorizedKeysFileSince') diff --git a/lib/gitlab/background_migration/update_vulnerability_confidence.rb b/lib/gitlab/background_migration/update_vulnerability_confidence.rb new file mode 100644 index 0000000000000000000000000000000000000000..6ffaa836f3c2176c342c4c2daa6c88211ef7cc40 --- /dev/null +++ b/lib/gitlab/background_migration/update_vulnerability_confidence.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Gitlab + module BackgroundMigration + # rubocop: disable Style/Documentation + class UpdateVulnerabilityConfidence + def perform(start_id, stop_id) + end + end + end +end + +Gitlab::BackgroundMigration::UpdateVulnerabilityConfidence.prepend_if_ee('EE::Gitlab::BackgroundMigration::UpdateVulnerabilityConfidence') diff --git a/lib/gitlab/backtrace_cleaner.rb b/lib/gitlab/backtrace_cleaner.rb new file mode 100644 index 0000000000000000000000000000000000000000..30ec99808f7a02d9896e9dc7cf8bb0b4ac154e0c --- /dev/null +++ b/lib/gitlab/backtrace_cleaner.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module Gitlab + module BacktraceCleaner + IGNORE_BACKTRACES = %w[ + config/initializers + ee/lib/gitlab/middleware/ + lib/gitlab/correlation_id.rb + lib/gitlab/database/load_balancing/ + lib/gitlab/etag_caching/ + lib/gitlab/i18n.rb + lib/gitlab/metrics/ + lib/gitlab/middleware/ + lib/gitlab/performance_bar/ + lib/gitlab/profiler.rb + lib/gitlab/query_limiting/ + lib/gitlab/request_context.rb + lib/gitlab/request_profiler/ + lib/gitlab/sidekiq_logging/ + lib/gitlab/sidekiq_middleware/ + lib/gitlab/sidekiq_status/ + lib/gitlab/tracing/ + lib/gitlab/webpack/dev_server_middleware.rb + ].freeze + + IGNORED_BACKTRACES_REGEXP = Regexp.union(IGNORE_BACKTRACES).freeze + + def self.clean_backtrace(backtrace) + return unless backtrace + + Array(Rails.backtrace_cleaner.clean(backtrace)).reject do |line| + line.match(IGNORED_BACKTRACES_REGEXP) + end + end + end +end diff --git a/lib/gitlab/bitbucket_import/importer.rb b/lib/gitlab/bitbucket_import/importer.rb index 67118aed5493f6bda0056c957b26298a2860b58b..3a087a3ef83062c44773a21a872bd2275a5bdc58 100644 --- a/lib/gitlab/bitbucket_import/importer.rb +++ b/lib/gitlab/bitbucket_import/importer.rb @@ -42,7 +42,7 @@ module Gitlab end def store_pull_request_error(pull_request, ex) - backtrace = Gitlab::Profiler.clean_backtrace(ex.backtrace) + backtrace = Gitlab::BacktraceCleaner.clean_backtrace(ex.backtrace) error = { type: :pull_request, iid: pull_request.iid, errors: ex.message, trace: backtrace, raw_response: pull_request.raw } Gitlab::ErrorTracking.log_exception(ex, error) @@ -182,7 +182,6 @@ module Gitlab target_branch_sha: target_branch_sha, state: pull_request.state, author_id: gitlab_user_id(project, pull_request.author), - assignee_id: nil, created_at: pull_request.created_at, updated_at: pull_request.updated_at ) diff --git a/lib/gitlab/bitbucket_server_import/importer.rb b/lib/gitlab/bitbucket_server_import/importer.rb index b7b2fe115c1fb0a9d5d7cd54d2c626fcbf719427..886fbaaff486e52dcfb6156366ea66db3013f5de 100644 --- a/lib/gitlab/bitbucket_server_import/importer.rb +++ b/lib/gitlab/bitbucket_server_import/importer.rb @@ -211,7 +211,6 @@ module Gitlab target_branch_sha: pull_request.target_branch_sha, state_id: MergeRequest.available_states[pull_request.state], author_id: author_id, - assignee_id: nil, created_at: pull_request.created_at, updated_at: pull_request.updated_at } diff --git a/lib/gitlab/ci/config/entry/includes.rb b/lib/gitlab/ci/config/entry/includes.rb index 43e74dfd628c6c0b5908df76f5a3863da6714179..24d0e27e3a775ec75413e1d5de10619c7bc29338 100644 --- a/lib/gitlab/ci/config/entry/includes.rb +++ b/lib/gitlab/ci/config/entry/includes.rb @@ -12,6 +12,15 @@ module Gitlab validations do validates :config, array_or_string: true + + validate do + next unless opt(:max_size) + next unless config.is_a?(Array) + + if config.size > opt(:max_size) + errors.add(:config, "is too long (maximum is #{opt(:max_size)})") + end + end end def self.aspects diff --git a/lib/gitlab/ci/config/entry/job.rb b/lib/gitlab/ci/config/entry/job.rb index 6a55b8cda57f6b6e2e51fc582e638e8999ec88b9..124581c961f904bcce7501af09d6c04b475610e6 100644 --- a/lib/gitlab/ci/config/entry/job.rb +++ b/lib/gitlab/ci/config/entry/job.rb @@ -16,7 +16,8 @@ module Gitlab ALLOWED_KEYS = %i[tags script only except rules type image services allow_failure type stage when start_in artifacts cache dependencies before_script needs after_script variables - environment coverage retry parallel extends interruptible timeout].freeze + environment coverage retry parallel extends interruptible timeout + resource_group release].freeze REQUIRED_BY_NEEDS = %i[stage].freeze @@ -34,6 +35,12 @@ module Gitlab message: 'key may not be used with `rules`' }, if: :has_rules? + validates :config, + disallowed_keys: { + in: %i[release], + message: 'release features are not enabled' + }, + unless: -> { Feature.enabled?(:ci_release_generation, default_enabled: false) } with_options allow_nil: true do validates :allow_failure, boolean: true @@ -48,16 +55,18 @@ module Gitlab validates :dependencies, array_of_strings: true validates :extends, array_of_strings_or_string: true validates :rules, array_of_hashes: true + validates :resource_group, type: String end validates :start_in, duration: { limit: '1 week' }, if: :delayed? validates :start_in, absence: true, if: -> { has_rules? || !delayed? } - validate do + validate on: :composed do next unless dependencies.present? - next unless needs.present? + next unless needs_value.present? + + missing_needs = dependencies - needs_value[:job].pluck(:name) # rubocop:disable CodeReuse/ActiveRecord (Array#pluck) - missing_needs = dependencies - needs if missing_needs.any? errors.add(:dependencies, "the #{missing_needs.join(", ")} should be part of needs") end @@ -149,14 +158,18 @@ module Gitlab description: 'Coverage configuration for this job.', inherit: false + entry :release, Entry::Release, + description: 'This job will produce a release.', + inherit: false + helpers :before_script, :script, :stage, :type, :after_script, :cache, :image, :services, :only, :except, :variables, :artifacts, :environment, :coverage, :retry, :rules, - :parallel, :needs, :interruptible + :parallel, :needs, :interruptible, :release attributes :script, :tags, :allow_failure, :when, :dependencies, :needs, :retry, :parallel, :extends, :start_in, :rules, - :interruptible, :timeout + :interruptible, :timeout, :resource_group, :release def self.matching?(name, config) !name.to_s.start_with?('.') && @@ -241,9 +254,11 @@ module Gitlab interruptible: interruptible_defined? ? interruptible_value : nil, timeout: has_timeout? ? ChronicDuration.parse(timeout.to_s) : nil, artifacts: artifacts_value, + release: release_value, after_script: after_script_value, ignore: ignored?, - needs: needs_defined? ? needs_value : nil } + needs: needs_defined? ? needs_value : nil, + resource_group: resource_group } end end end diff --git a/lib/gitlab/ci/config/entry/release.rb b/lib/gitlab/ci/config/entry/release.rb new file mode 100644 index 0000000000000000000000000000000000000000..3eceaa0ccd994b7df0c8c3ebdd259867c67d5236 --- /dev/null +++ b/lib/gitlab/ci/config/entry/release.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + class Config + module Entry + ## + # Entry that represents a release configuration. + # + class Release < ::Gitlab::Config::Entry::Node + include ::Gitlab::Config::Entry::Configurable + include ::Gitlab::Config::Entry::Validatable + include ::Gitlab::Config::Entry::Attributable + + ALLOWED_KEYS = %i[tag_name name description assets].freeze + attributes %i[tag_name name assets].freeze + + # Attributable description conflicts with + # ::Gitlab::Config::Entry::Node.description + def has_description? + true + end + + def description + config[:description] + end + + entry :assets, Entry::Release::Assets, description: 'Release assets.' + + validations do + validates :config, allowed_keys: ALLOWED_KEYS + validates :tag_name, presence: true + validates :description, type: String, presence: true + end + + helpers :assets + + def value + @config[:assets] = assets_value if @config.key?(:assets) + @config + end + end + end + end + end +end diff --git a/lib/gitlab/ci/config/entry/release/assets.rb b/lib/gitlab/ci/config/entry/release/assets.rb new file mode 100644 index 0000000000000000000000000000000000000000..82ed39f51e01b3a4e5a52c8ee262ec19f02e1267 --- /dev/null +++ b/lib/gitlab/ci/config/entry/release/assets.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + class Config + module Entry + ## + # Entry that represents a configuration of release assets. + # + class Release + class Assets < ::Gitlab::Config::Entry::Node + include ::Gitlab::Config::Entry::Configurable + include ::Gitlab::Config::Entry::Validatable + include ::Gitlab::Config::Entry::Attributable + + ALLOWED_KEYS = %i[links].freeze + attributes ALLOWED_KEYS + + entry :links, Entry::Release::Assets::Links, description: 'Release assets:links.' + + validations do + validates :config, allowed_keys: ALLOWED_KEYS + validates :links, array_of_hashes: true, presence: true + end + + helpers :links + + def value + @config[:links] = links_value if @config.key?(:links) + @config + end + end + end + end + end + end +end diff --git a/lib/gitlab/ci/config/entry/release/assets/link.rb b/lib/gitlab/ci/config/entry/release/assets/link.rb new file mode 100644 index 0000000000000000000000000000000000000000..8e8fcde16a36f8b4624d9a3e9f496596bef37045 --- /dev/null +++ b/lib/gitlab/ci/config/entry/release/assets/link.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + class Config + module Entry + ## + # Entry that represents a configuration of release:assets:links. + # + class Release + class Assets + class Link < ::Gitlab::Config::Entry::Node + include ::Gitlab::Config::Entry::Validatable + include ::Gitlab::Config::Entry::Attributable + + ALLOWED_KEYS = %i[name url].freeze + + attributes ALLOWED_KEYS + + validations do + validates :config, allowed_keys: ALLOWED_KEYS + + validates :name, type: String, presence: true + validates :url, presence: true, addressable_url: true + end + end + end + end + end + end + end +end diff --git a/lib/gitlab/ci/config/entry/release/assets/links.rb b/lib/gitlab/ci/config/entry/release/assets/links.rb new file mode 100644 index 0000000000000000000000000000000000000000..b791d173d54741d538482f9105555606dfabdc12 --- /dev/null +++ b/lib/gitlab/ci/config/entry/release/assets/links.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + class Config + module Entry + ## + # Entry that represents a configuration of release:assets:links. + # + class Release + class Assets + class Links < ::Gitlab::Config::Entry::Node + include ::Gitlab::Config::Entry::Configurable + include ::Gitlab::Config::Entry::Validatable + + entry :link, Entry::Release::Assets::Link, description: 'Release assets:links:link.' + + validations do + validates :config, type: Array, presence: true + end + + def skip_config_hash_validation? + true + end + end + end + end + end + end + end +end diff --git a/lib/gitlab/ci/config/entry/reports.rb b/lib/gitlab/ci/config/entry/reports.rb index eeaac52eefd5af86763b3eb8f2821964ba2a05b2..f984d7d397aaf87d8216bade8800fab565dea8dd 100644 --- a/lib/gitlab/ci/config/entry/reports.rb +++ b/lib/gitlab/ci/config/entry/reports.rb @@ -11,7 +11,7 @@ module Gitlab include ::Gitlab::Config::Entry::Validatable include ::Gitlab::Config::Entry::Attributable - ALLOWED_KEYS = %i[junit codequality sast dependency_scanning container_scanning dast performance license_management metrics].freeze + ALLOWED_KEYS = %i[junit codequality sast dependency_scanning container_scanning dast performance license_management license_scanning metrics].freeze attributes ALLOWED_KEYS @@ -28,6 +28,7 @@ module Gitlab validates :dast, array_of_strings_or_string: true validates :performance, array_of_strings_or_string: true validates :license_management, array_of_strings_or_string: true + validates :license_scanning, array_of_strings_or_string: true validates :metrics, array_of_strings_or_string: true end end diff --git a/lib/gitlab/ci/pipeline/chain/command.rb b/lib/gitlab/ci/pipeline/chain/command.rb index c2df419cca07c2d0c702061e3d4dd6efd618efdf..6a16e6df23dc77d3b82bab1b5b133940c7d5661d 100644 --- a/lib/gitlab/ci/pipeline/chain/command.rb +++ b/lib/gitlab/ci/pipeline/chain/command.rb @@ -10,7 +10,7 @@ module Gitlab :trigger_request, :schedule, :merge_request, :external_pull_request, :ignore_skip_ci, :save_incompleted, :seeds_block, :variables_attributes, :push_options, - :chat_data, :allow_mirror_update, + :chat_data, :allow_mirror_update, :bridge, # These attributes are set by Chains during processing: :config_content, :config_processor, :stage_seeds ) do @@ -22,12 +22,6 @@ module Gitlab end end - def uses_unsupported_legacy_trigger? - trigger_request.present? && - trigger_request.trigger.legacy? && - !trigger_request.trigger.supports_legacy_tokens? - end - def branch_exists? strong_memoize(:is_branch) do project.repository.branch_exists?(ref) diff --git a/lib/gitlab/ci/pipeline/chain/config/content.rb b/lib/gitlab/ci/pipeline/chain/config/content.rb index d4b7444005e96c19055c2f1a86ecaa52e53482d8..66bead3a416462af551a18c2831794aaa57b9ee4 100644 --- a/lib/gitlab/ci/pipeline/chain/config/content.rb +++ b/lib/gitlab/ci/pipeline/chain/config/content.rb @@ -9,7 +9,7 @@ module Gitlab include Chain::Helpers SOURCES = [ - Gitlab::Ci::Pipeline::Chain::Config::Content::Runtime, + Gitlab::Ci::Pipeline::Chain::Config::Content::Bridge, Gitlab::Ci::Pipeline::Chain::Config::Content::Repository, Gitlab::Ci::Pipeline::Chain::Config::Content::ExternalProject, Gitlab::Ci::Pipeline::Chain::Config::Content::Remote, @@ -17,15 +17,14 @@ module Gitlab ].freeze LEGACY_SOURCES = [ - Gitlab::Ci::Pipeline::Chain::Config::Content::Runtime, + Gitlab::Ci::Pipeline::Chain::Config::Content::Bridge, Gitlab::Ci::Pipeline::Chain::Config::Content::LegacyRepository, Gitlab::Ci::Pipeline::Chain::Config::Content::LegacyAutoDevops ].freeze def perform! if config = find_config - # TODO: we should persist config_content - # @pipeline.config_content = config.content + @pipeline.build_pipeline_config(content: config.content) if ci_root_config_content_enabled? @command.config_content = config.content @pipeline.config_source = config.source else @@ -49,11 +48,11 @@ module Gitlab end def sources - if Feature.enabled?(:ci_root_config_content, @command.project, default_enabled: true) - SOURCES - else - LEGACY_SOURCES - end + ci_root_config_content_enabled? ? SOURCES : LEGACY_SOURCES + end + + def ci_root_config_content_enabled? + Feature.enabled?(:ci_root_config_content, @command.project, default_enabled: true) end end end diff --git a/lib/gitlab/ci/pipeline/chain/config/content/auto_devops.rb b/lib/gitlab/ci/pipeline/chain/config/content/auto_devops.rb index e9bcc67de9c12cdb18e9fe354711c042cc13b4b2..54be789988c09e25bcfd4a73b28168f9f29acf7e 100644 --- a/lib/gitlab/ci/pipeline/chain/config/content/auto_devops.rb +++ b/lib/gitlab/ci/pipeline/chain/config/content/auto_devops.rb @@ -11,7 +11,7 @@ module Gitlab strong_memoize(:content) do next unless project&.auto_devops_enabled? - template = Gitlab::Template::GitlabCiYmlTemplate.find('Auto-DevOps') + template = Gitlab::Template::GitlabCiYmlTemplate.find(template_name) YAML.dump('include' => [{ 'template' => template.full_name }]) end end @@ -19,6 +19,22 @@ module Gitlab def source :auto_devops_source end + + private + + def template_name + if beta_enabled? + 'Beta/Auto-DevOps' + else + 'Auto-DevOps' + end + end + + def beta_enabled? + Feature.enabled?(:auto_devops_beta, project, default_enabled: true) && + # workflow:rules are required by `Beta/Auto-DevOps.gitlab-ci.yml` + Feature.enabled?(:workflow_rules, project, default_enabled: true) + end end end end diff --git a/lib/gitlab/ci/pipeline/chain/config/content/bridge.rb b/lib/gitlab/ci/pipeline/chain/config/content/bridge.rb new file mode 100644 index 0000000000000000000000000000000000000000..39ffa2d4e257f35cc10f2555212afc75ccc88e21 --- /dev/null +++ b/lib/gitlab/ci/pipeline/chain/config/content/bridge.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + module Pipeline + module Chain + module Config + class Content + class Bridge < Source + def content + return unless @command.bridge + + @command.bridge.yaml_for_downstream + end + + def source + :bridge_source + end + end + end + end + end + end + end +end diff --git a/lib/gitlab/ci/pipeline/chain/config/content/legacy_auto_devops.rb b/lib/gitlab/ci/pipeline/chain/config/content/legacy_auto_devops.rb index c4cef356628ac905fc4c82dde5d75875306620f2..b282886a56f6797484e97604f9b894d0be3219c5 100644 --- a/lib/gitlab/ci/pipeline/chain/config/content/legacy_auto_devops.rb +++ b/lib/gitlab/ci/pipeline/chain/config/content/legacy_auto_devops.rb @@ -11,7 +11,7 @@ module Gitlab strong_memoize(:content) do next unless project&.auto_devops_enabled? - template = Gitlab::Template::GitlabCiYmlTemplate.find('Auto-DevOps') + template = Gitlab::Template::GitlabCiYmlTemplate.find(template_name) template.content end end @@ -19,6 +19,22 @@ module Gitlab def source :auto_devops_source end + + private + + def template_name + if beta_enabled? + 'Beta/Auto-DevOps' + else + 'Auto-DevOps' + end + end + + def beta_enabled? + Feature.enabled?(:auto_devops_beta, project, default_enabled: true) && + # workflow:rules are required by `Beta/Auto-DevOps.gitlab-ci.yml` + Feature.enabled?(:workflow_rules, project, default_enabled: true) + end end end end diff --git a/lib/gitlab/ci/pipeline/chain/config/content/runtime.rb b/lib/gitlab/ci/pipeline/chain/config/content/runtime.rb deleted file mode 100644 index 4811d3d913d35f406054e80c5024d2549a395422..0000000000000000000000000000000000000000 --- a/lib/gitlab/ci/pipeline/chain/config/content/runtime.rb +++ /dev/null @@ -1,30 +0,0 @@ -# frozen_string_literal: true - -module Gitlab - module Ci - module Pipeline - module Chain - module Config - class Content - class Runtime < Source - def content - @command.config_content - end - - def source - # The only case when this source is used is when the config content - # is passed in as parameter to Ci::CreatePipelineService. - # This would only occur with parent/child pipelines which is being - # implemented. - # TODO: change source to return :runtime_source - # https://gitlab.com/gitlab-org/gitlab/merge_requests/21041 - - nil - end - end - end - end - end - end - end -end diff --git a/lib/gitlab/ci/pipeline/chain/validate/abilities.rb b/lib/gitlab/ci/pipeline/chain/validate/abilities.rb index f9ed9d911778e3ff1b23ca0f94c82246f5347088..a30b6c6ef0e2d90788c6adeaef808ff76fa9103d 100644 --- a/lib/gitlab/ci/pipeline/chain/validate/abilities.rb +++ b/lib/gitlab/ci/pipeline/chain/validate/abilities.rb @@ -14,16 +14,12 @@ module Gitlab return error('Pipelines are disabled!') end - if @command.uses_unsupported_legacy_trigger? - return error('Trigger token is invalid because is not owned by any user') + unless allowed_to_create_pipeline? + return error('Insufficient permissions to create a new pipeline') end - unless allowed_to_trigger_pipeline? - if can?(current_user, :create_pipeline, project) - return error("Insufficient permissions for protected ref '#{command.ref}'") - else - return error('Insufficient permissions to create a new pipeline') - end + unless allowed_to_write_ref? + return error("Insufficient permissions for protected ref '#{command.ref}'") end end @@ -31,17 +27,13 @@ module Gitlab @pipeline.errors.any? end - def allowed_to_trigger_pipeline? - if current_user - allowed_to_create? - else # legacy triggers don't have a corresponding user - !@command.protected_ref? - end - end + private - def allowed_to_create? - return unless can?(current_user, :create_pipeline, project) + def allowed_to_create_pipeline? + can?(current_user, :create_pipeline, project) + end + def allowed_to_write_ref? access = Gitlab::UserAccess.new(current_user, project: project) if @command.branch_exists? diff --git a/lib/gitlab/ci/pipeline/seed/build.rb b/lib/gitlab/ci/pipeline/seed/build.rb index 590c7f4d1ddbfddc5ad81305842a418668efe391..98b4b4593e0fb94ec6d1efec01c6ded77d76cae3 100644 --- a/lib/gitlab/ci/pipeline/seed/build.rb +++ b/lib/gitlab/ci/pipeline/seed/build.rb @@ -18,6 +18,7 @@ module Gitlab @seed_attributes = attributes @previous_stages = previous_stages @needs_attributes = dig(:needs_attributes) + @resource_group_key = attributes.delete(:resource_group_key) @using_rules = attributes.key?(:rules) @using_only = attributes.key?(:only) @@ -78,6 +79,7 @@ module Gitlab else ::Ci::Build.new(attributes).tap do |job| job.deployment = Seed::Deployment.new(job).to_resource + job.resource_group = Seed::Build::ResourceGroup.new(job, @resource_group_key).to_resource end end end diff --git a/lib/gitlab/ci/pipeline/seed/build/resource_group.rb b/lib/gitlab/ci/pipeline/seed/build/resource_group.rb new file mode 100644 index 0000000000000000000000000000000000000000..3bec6d1e8b6429f3da7b599aa08db6c3303c68ed --- /dev/null +++ b/lib/gitlab/ci/pipeline/seed/build/resource_group.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + module Pipeline + module Seed + class Build + class ResourceGroup < Seed::Base + include Gitlab::Utils::StrongMemoize + + attr_reader :build, :resource_group_key + + def initialize(build, resource_group_key) + @build = build + @resource_group_key = resource_group_key + end + + def to_resource + return unless Feature.enabled?(:ci_resource_group, build.project, default_enabled: true) + return unless resource_group_key.present? + + resource_group = build.project.resource_groups + .safe_find_or_create_by(key: expanded_resource_group_key) + + resource_group if resource_group.persisted? + end + + private + + def expanded_resource_group_key + strong_memoize(:expanded_resource_group_key) do + ExpandVariables.expand(resource_group_key, -> { build.simple_variables }) + end + end + end + end + end + end + end +end diff --git a/lib/gitlab/ci/snippets/review_app_default.yml b/lib/gitlab/ci/snippets/review_app_default.yml new file mode 100644 index 0000000000000000000000000000000000000000..b6db08ef5375c2bd6878eb556626c6cc455b010f --- /dev/null +++ b/lib/gitlab/ci/snippets/review_app_default.yml @@ -0,0 +1,9 @@ +deploy_review: + stage: deploy + script: + - echo "Deploy a review app" + environment: + name: review/$CI_COMMIT_REF_NAME + url: https://$CI_ENVIRONMENT_SLUG.example.com + only: + - branches diff --git a/lib/gitlab/ci/status/build/factory.rb b/lib/gitlab/ci/status/build/factory.rb index 96d058428385310d4bb6ce70f7200ceaef8e52cb..7e5afbad80655230c62575b557d05b009052fa15 100644 --- a/lib/gitlab/ci/status/build/factory.rb +++ b/lib/gitlab/ci/status/build/factory.rb @@ -11,6 +11,7 @@ module Gitlab Status::Build::Manual, Status::Build::Canceled, Status::Build::Created, + Status::Build::WaitingForResource, Status::Build::Preparing, Status::Build::Pending, Status::Build::Skipped], diff --git a/lib/gitlab/ci/status/build/waiting_for_resource.rb b/lib/gitlab/ci/status/build/waiting_for_resource.rb new file mode 100644 index 0000000000000000000000000000000000000000..008e6a17bddb97d274fad856fdcbebf9f5118848 --- /dev/null +++ b/lib/gitlab/ci/status/build/waiting_for_resource.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + module Status + module Build + class WaitingForResource < Status::Extended + ## + # TODO: image is shared with 'pending' + # until we get a dedicated one + # + def illustration + { + image: 'illustrations/pending_job_empty.svg', + size: 'svg-430', + title: _('This job is waiting for resource: ') + subject.resource_group.key + } + end + + def self.matches?(build, _) + build.waiting_for_resource? + end + end + end + end + end +end diff --git a/lib/gitlab/ci/status/composite.rb b/lib/gitlab/ci/status/composite.rb index 3c00b67911ffa4ee0a2980bd99844fbcfc264aad..074651f10406850f732e65235c6e38ee789ee6eb 100644 --- a/lib/gitlab/ci/status/composite.rb +++ b/lib/gitlab/ci/status/composite.rb @@ -25,6 +25,8 @@ module Gitlab # 2. In other cases we assume that status is of that type # based on what statuses are no longer valid based on the # data set that we have + # rubocop: disable Metrics/CyclomaticComplexity + # rubocop: disable Metrics/PerceivedComplexity def status return if none? @@ -43,6 +45,8 @@ module Gitlab 'pending' elsif any_of?(:running, :pending) 'running' + elsif any_of?(:waiting_for_resource) + 'waiting_for_resource' elsif any_of?(:manual) 'manual' elsif any_of?(:scheduled) @@ -56,6 +60,8 @@ module Gitlab end end end + # rubocop: enable Metrics/CyclomaticComplexity + # rubocop: enable Metrics/PerceivedComplexity def warnings? @status_set.include?(:success_with_warnings) diff --git a/lib/gitlab/ci/status/factory.rb b/lib/gitlab/ci/status/factory.rb index c29dc51f076de527e00610ccfc2028eacac2f7b5..73c73a3b3fc09b651a031ceb43847db39c1355e7 100644 --- a/lib/gitlab/ci/status/factory.rb +++ b/lib/gitlab/ci/status/factory.rb @@ -20,7 +20,7 @@ module Gitlab def core_status Gitlab::Ci::Status - .const_get(@status.capitalize, false) + .const_get(@status.to_s.camelize, false) .new(@subject, @user) .extend(self.class.common_helpers) end diff --git a/lib/gitlab/ci/status/waiting_for_resource.rb b/lib/gitlab/ci/status/waiting_for_resource.rb new file mode 100644 index 0000000000000000000000000000000000000000..4c9e706bc517f1a65b44242aee4b25cdeef2fced --- /dev/null +++ b/lib/gitlab/ci/status/waiting_for_resource.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + module Status + class WaitingForResource < Status::Core + def text + s_('CiStatusText|waiting') + end + + def label + s_('CiStatusLabel|waiting for resource') + end + + def icon + 'status_pending' + end + + def favicon + 'favicon_pending' + end + + def group + 'waiting-for-resource' + end + end + end + end +end diff --git a/lib/gitlab/ci/templates/Beta/Auto-DevOps.gitlab-ci.yml b/lib/gitlab/ci/templates/Beta/Auto-DevOps.gitlab-ci.yml new file mode 100644 index 0000000000000000000000000000000000000000..2c5035705ac5bc76e1dba847ecae0f53b19decd7 --- /dev/null +++ b/lib/gitlab/ci/templates/Beta/Auto-DevOps.gitlab-ci.yml @@ -0,0 +1,163 @@ +# Auto DevOps - BETA do not use +# This CI/CD configuration provides a standard pipeline for +# * building a Docker image (using a buildpack if necessary), +# * storing the image in the container registry, +# * running tests from a buildpack, +# * running code quality analysis, +# * creating a review app for each topic branch, +# * and continuous deployment to production +# +# Test jobs may be disabled by setting environment variables: +# * test: TEST_DISABLED +# * code_quality: CODE_QUALITY_DISABLED +# * license_management: LICENSE_MANAGEMENT_DISABLED +# * performance: PERFORMANCE_DISABLED +# * sast: SAST_DISABLED +# * dependency_scanning: DEPENDENCY_SCANNING_DISABLED +# * container_scanning: CONTAINER_SCANNING_DISABLED +# * dast: DAST_DISABLED +# * review: REVIEW_DISABLED +# * stop_review: REVIEW_DISABLED +# +# In order to deploy, you must have a Kubernetes cluster configured either +# via a project integration, or via group/project variables. +# KUBE_INGRESS_BASE_DOMAIN must also be set on the cluster settings, +# as a variable at the group or project level, or manually added below. +# +# Continuous deployment to production is enabled by default. +# If you want to deploy to staging first, set STAGING_ENABLED environment variable. +# If you want to enable incremental rollout, either manual or time based, +# set INCREMENTAL_ROLLOUT_MODE environment variable to "manual" or "timed". +# If you want to use canary deployments, set CANARY_ENABLED environment variable. +# +# If Auto DevOps fails to detect the proper buildpack, or if you want to +# specify a custom buildpack, set a project variable `BUILDPACK_URL` to the +# repository URL of the buildpack. +# e.g. BUILDPACK_URL=https://github.com/heroku/heroku-buildpack-ruby.git#v142 +# If you need multiple buildpacks, add a file to your project called +# `.buildpacks` that contains the URLs, one on each line, in order. +# Note: Auto CI does not work with multiple buildpacks yet + +image: alpine:latest + +variables: + # KUBE_INGRESS_BASE_DOMAIN is the application deployment domain and should be set as a variable at the group or project level. + # KUBE_INGRESS_BASE_DOMAIN: domain.example.com + + POSTGRES_USER: user + POSTGRES_PASSWORD: testing-password + POSTGRES_ENABLED: "true" + POSTGRES_DB: $CI_ENVIRONMENT_SLUG + POSTGRES_VERSION: 9.6.2 + + DOCKER_DRIVER: overlay2 + + ROLLOUT_RESOURCE_TYPE: deployment + + DOCKER_TLS_CERTDIR: "" # https://gitlab.com/gitlab-org/gitlab-runner/issues/4501 + +stages: + - build + - test + - deploy # dummy stage to follow the template guidelines + - review + - dast + - staging + - canary + - production + - incremental rollout 10% + - incremental rollout 25% + - incremental rollout 50% + - incremental rollout 100% + - performance + - cleanup + +workflow: + rules: + - if: '$BUILDPACK_URL || $AUTO_DEVOPS_EXPLICITLY_ENABLED == "1"' + + - exists: + - Dockerfile + + # https://github.com/heroku/heroku-buildpack-clojure + - exists: + - project.clj + + # https://github.com/heroku/heroku-buildpack-go + - exists: + - go.mod + - Gopkg.mod + - Godeps/Godeps.json + - vendor/vendor.json + - glide.yaml + - src/**/*.go + + # https://github.com/heroku/heroku-buildpack-gradle + - exists: + - gradlew + - build.gradle + - settings.gradle + + # https://github.com/heroku/heroku-buildpack-java + - exists: + - pom.xml + - pom.atom + - pom.clj + - pom.groovy + - pom.rb + - pom.scala + - pom.yaml + - pom.yml + + # https://github.com/heroku/heroku-buildpack-multi + - exists: + - .buildpacks + + # https://github.com/heroku/heroku-buildpack-nodejs + - exists: + - package.json + + # https://github.com/heroku/heroku-buildpack-php + - exists: + - composer.json + - index.php + + # https://github.com/heroku/heroku-buildpack-play + # TODO: detect script excludes some scala files + - exists: + - '**/conf/application.conf' + + # https://github.com/heroku/heroku-buildpack-python + # TODO: detect script checks that all of these exist, not any + - exists: + - requirements.txt + - setup.py + - Pipfile + + # https://github.com/heroku/heroku-buildpack-ruby + - exists: + - Gemfile + + # https://github.com/heroku/heroku-buildpack-scala + - exists: + - '*.sbt' + - project/*.scala + - .sbt/*.scala + - project/build.properties + + # https://github.com/dokku/buildpack-nginx + - exists: + - .static + +include: + - template: Jobs/Build.gitlab-ci.yml # https://gitlab.com/gitlab-org/gitlab-foss/blob/master/lib/gitlab/ci/templates/Jobs/Build.gitlab-ci.yml + - template: Jobs/Test.gitlab-ci.yml # https://gitlab.com/gitlab-org/gitlab-foss/blob/master/lib/gitlab/ci/templates/Jobs/Test.gitlab-ci.yml + - template: Jobs/Code-Quality.gitlab-ci.yml # https://gitlab.com/gitlab-org/gitlab-foss/blob/master/lib/gitlab/ci/templates/Jobs/Code-Quality.gitlab-ci.yml + - template: Jobs/Deploy.gitlab-ci.yml # https://gitlab.com/gitlab-org/gitlab-foss/blob/master/lib/gitlab/ci/templates/Jobs/Deploy.gitlab-ci.yml + - template: Jobs/DAST-Default-Branch-Deploy.gitlab-ci.yml # https://gitlab.com/gitlab-org/gitlab-foss/blob/master/lib/gitlab/ci/templates/Jobs/DAST-Default-Branch-Deploy.gitlab-ci.yml + - template: Jobs/Browser-Performance-Testing.gitlab-ci.yml # https://gitlab.com/gitlab-org/gitlab-foss/blob/master/lib/gitlab/ci/templates/Jobs/Browser-Performance-Testing.gitlab-ci.yml + - template: Security/DAST.gitlab-ci.yml # https://gitlab.com/gitlab-org/gitlab-foss/blob/master/lib/gitlab/ci/templates/Security/DAST.gitlab-ci.yml + - template: Security/Container-Scanning.gitlab-ci.yml # https://gitlab.com/gitlab-org/gitlab-foss/blob/master/lib/gitlab/ci/templates/Security/Container-Scanning.gitlab-ci.yml + - template: Security/Dependency-Scanning.gitlab-ci.yml # https://gitlab.com/gitlab-org/gitlab-foss/blob/master/lib/gitlab/ci/templates/Security/Dependency-Scanning.gitlab-ci.yml + - template: Security/License-Management.gitlab-ci.yml # https://gitlab.com/gitlab-org/gitlab-foss/blob/master/lib/gitlab/ci/templates/Security/License-Management.gitlab-ci.yml + - template: Security/SAST.gitlab-ci.yml # https://gitlab.com/gitlab-org/gitlab-foss/blob/master/lib/gitlab/ci/templates/Security/SAST.gitlab-ci.yml diff --git a/lib/gitlab/ci/templates/Jobs/Code-Quality.gitlab-ci.yml b/lib/gitlab/ci/templates/Jobs/Code-Quality.gitlab-ci.yml index 1708984c1cbf865ccb0f5c9bf5f03b11df8fb3fa..8bc60a36ebd62d72696e070040555ee1f9d98348 100644 --- a/lib/gitlab/ci/templates/Jobs/Code-Quality.gitlab-ci.yml +++ b/lib/gitlab/ci/templates/Jobs/Code-Quality.gitlab-ci.yml @@ -7,7 +7,7 @@ code_quality: variables: DOCKER_DRIVER: overlay2 DOCKER_TLS_CERTDIR: "" - CODE_QUALITY_IMAGE: "registry.gitlab.com/gitlab-org/security-products/codequality:0.85.5" + CODE_QUALITY_IMAGE: "registry.gitlab.com/gitlab-org/security-products/codequality:0.85.6" script: - | if ! docker info &>/dev/null; then diff --git a/lib/gitlab/ci/templates/Jobs/DAST-Default-Branch-Deploy.gitlab-ci.yml b/lib/gitlab/ci/templates/Jobs/DAST-Default-Branch-Deploy.gitlab-ci.yml index 7a672f910dda564ec37d3038a94b8b8ad8a6b00e..feedb0994c2ec6181bf1a2d9822d47f9e117006d 100644 --- a/lib/gitlab/ci/templates/Jobs/DAST-Default-Branch-Deploy.gitlab-ci.yml +++ b/lib/gitlab/ci/templates/Jobs/DAST-Default-Branch-Deploy.gitlab-ci.yml @@ -1,5 +1,5 @@ .dast-auto-deploy: - image: "registry.gitlab.com/gitlab-org/cluster-integration/auto-deploy-image:v0.6.0" + image: "registry.gitlab.com/gitlab-org/cluster-integration/auto-deploy-image:v0.8.3" dast_environment_deploy: extends: .dast-auto-deploy diff --git a/lib/gitlab/ci/templates/Managed-Cluster-Applications.gitlab-ci.yml b/lib/gitlab/ci/templates/Managed-Cluster-Applications.gitlab-ci.yml index 9a5b0f79ecf94d134686a3d14b31faad8248f47d..93c69772b010e2e28356fcedd9ca38a8ab330bf1 100644 --- a/lib/gitlab/ci/templates/Managed-Cluster-Applications.gitlab-ci.yml +++ b/lib/gitlab/ci/templates/Managed-Cluster-Applications.gitlab-ci.yml @@ -1,16 +1,21 @@ apply: stage: deploy - image: "registry.gitlab.com/gitlab-org/cluster-integration/cluster-applications:v0.3.0" + image: "registry.gitlab.com/gitlab-org/cluster-integration/cluster-applications:v0.5.0" environment: name: production variables: TILLER_NAMESPACE: gitlab-managed-apps GITLAB_MANAGED_APPS_FILE: $CI_PROJECT_DIR/.gitlab/managed-apps/config.yaml INGRESS_VALUES_FILE: $CI_PROJECT_DIR/.gitlab/managed-apps/ingress/values.yaml + CERT_MANAGER_VALUES_FILE: $CI_PROJECT_DIR/.gitlab/managed-apps/cert-manager/values.yaml SENTRY_VALUES_FILE: $CI_PROJECT_DIR/.gitlab/managed-apps/sentry/values.yaml + GITLAB_RUNNER_VALUES_FILE: $CI_PROJECT_DIR/.gitlab/managed-apps/gitlab-runner/values.yaml script: - - kubectl get namespace "$TILLER_NAMESPACE" || kubectl create namespace "$TILLER_NAMESPACE" - gitlab-managed-apps /usr/local/share/gitlab-managed-apps/helmfile.yaml only: refs: - master + artifacts: + when: on_failure + paths: + - tiller.log diff --git a/lib/gitlab/ci/templates/Security/DAST.gitlab-ci.yml b/lib/gitlab/ci/templates/Security/DAST.gitlab-ci.yml index 23c65a0cb67b8d5904fc466191d8e8a6a076e421..94b9d94fd39ab3ad3f8f0f5f8d6bee104e3b81bb 100644 --- a/lib/gitlab/ci/templates/Security/DAST.gitlab-ci.yml +++ b/lib/gitlab/ci/templates/Security/DAST.gitlab-ci.yml @@ -10,10 +10,13 @@ stages: - deploy - dast +variables: + DAST_VERSION: 1 + dast: stage: dast image: - name: "registry.gitlab.com/gitlab-org/security-products/dast:$CI_SERVER_VERSION_MAJOR-$CI_SERVER_VERSION_MINOR-stable" + name: "registry.gitlab.com/gitlab-org/security-products/dast:$DAST_VERSION" variables: # URL to scan: # DAST_WEBSITE: https://example.com/ diff --git a/lib/gitlab/ci/templates/Security/Dependency-Scanning.gitlab-ci.yml b/lib/gitlab/ci/templates/Security/Dependency-Scanning.gitlab-ci.yml index d73f6ccdb3f042e6719c349c84ed069c8394abe3..225fb7e5606c8334f6379144a2305b0201ca8778 100644 --- a/lib/gitlab/ci/templates/Security/Dependency-Scanning.gitlab-ci.yml +++ b/lib/gitlab/ci/templates/Security/Dependency-Scanning.gitlab-ci.yml @@ -49,6 +49,9 @@ dependency_scanning: DS_PYTHON_VERSION \ DS_PIP_VERSION \ DS_PIP_DEPENDENCY_PATH \ + GEMNASIUM_DB_LOCAL_PATH \ + GEMNASIUM_DB_REMOTE_URL \ + GEMNASIUM_DB_REF_NAME \ PIP_INDEX_URL \ PIP_EXTRA_INDEX_URL \ PIP_REQUIREMENTS_FILE \ @@ -77,6 +80,7 @@ dependency_scanning: services: [] except: variables: + - $DEPENDENCY_SCANNING_DISABLED - $DS_DISABLE_DIND == 'false' script: - /analyzer run @@ -88,8 +92,8 @@ gemnasium-dependency_scanning: only: variables: - $GITLAB_FEATURES =~ /\bdependency_scanning\b/ && - $DS_DEFAULT_ANALYZERS =~ /gemnasium/ && - $CI_PROJECT_REPOSITORY_LANGUAGES =~ /ruby|javascript|php/ + $DS_DEFAULT_ANALYZERS =~ /gemnasium([^-]|$)/ && + $CI_PROJECT_REPOSITORY_LANGUAGES =~ /ruby|javascript|php|\bgo\b/ gemnasium-maven-dependency_scanning: extends: .ds-analyzer diff --git a/lib/gitlab/ci/templates/Security/SAST.gitlab-ci.yml b/lib/gitlab/ci/templates/Security/SAST.gitlab-ci.yml index 34d84138a8bd9c64579868cc95da474e0fa6b66a..864e3eb569d4e2120a0bf7b93368d6e9b122db2a 100644 --- a/lib/gitlab/ci/templates/Security/SAST.gitlab-ci.yml +++ b/lib/gitlab/ci/templates/Security/SAST.gitlab-ci.yml @@ -55,6 +55,7 @@ sast: services: [] except: variables: + - $SAST_DISABLED - $SAST_DISABLE_DIND == 'false' script: - /analyzer run diff --git a/lib/gitlab/ci/trace.rb b/lib/gitlab/ci/trace.rb index 941f7178dacf36fce67fdfa538f1d8ef50e5d6da..4e83826b2493db2c1cc0bb48a116b613448af302 100644 --- a/lib/gitlab/ci/trace.rb +++ b/lib/gitlab/ci/trace.rb @@ -9,6 +9,10 @@ module Gitlab LOCK_TTL = 10.minutes LOCK_RETRIES = 2 LOCK_SLEEP = 0.001.seconds + WATCH_FLAG_TTL = 10.seconds + + UPDATE_FREQUENCY_DEFAULT = 30.seconds + UPDATE_FREQUENCY_WHEN_BEING_WATCHED = 3.seconds ArchiveError = Class.new(StandardError) AlreadyArchivedError = Class.new(StandardError) @@ -119,6 +123,22 @@ module Gitlab end end + def update_interval + being_watched? ? UPDATE_FREQUENCY_WHEN_BEING_WATCHED : UPDATE_FREQUENCY_DEFAULT + end + + def being_watched! + Gitlab::Redis::SharedState.with do |redis| + redis.set(being_watched_cache_key, true, ex: WATCH_FLAG_TTL) + end + end + + def being_watched? + Gitlab::Redis::SharedState.with do |redis| + redis.exists(being_watched_cache_key) + end + end + private def unsafe_write!(mode, &blk) @@ -236,6 +256,10 @@ module Gitlab def trace_artifact job.job_artifacts_trace end + + def being_watched_cache_key + "gitlab:ci:trace:#{job.id}:watched" + end end end end diff --git a/lib/gitlab/ci/yaml_processor.rb b/lib/gitlab/ci/yaml_processor.rb index 27cd4f5fd6b7a405c0add91633c2b7adb46bb53f..080a8ac107d3e2d685de21ccf309a4328705bd62 100644 --- a/lib/gitlab/ci/yaml_processor.rb +++ b/lib/gitlab/ci/yaml_processor.rb @@ -64,6 +64,7 @@ module Gitlab except: job[:except], rules: job[:rules], cache: job[:cache], + resource_group_key: job[:resource_group], options: { image: job[:image], services: job[:services], @@ -80,10 +81,15 @@ module Gitlab instance: job[:instance], start_in: job[:start_in], trigger: job[:trigger], - bridge_needs: job.dig(:needs, :bridge)&.first + bridge_needs: job.dig(:needs, :bridge)&.first, + release: release(job) }.compact }.compact end + def release(job) + job[:release] if Feature.enabled?(:ci_release_generation, default_enabled: false) + end + def stage_builds_attributes(stage) @jobs.values .select { |job| job[:stage] == stage } @@ -132,7 +138,6 @@ module Gitlab @jobs.each do |name, job| # logical validation for job - validate_job_stage!(name, job) validate_job_dependencies!(name, job) validate_job_needs!(name, job) diff --git a/lib/gitlab/closing_issue_extractor.rb b/lib/gitlab/closing_issue_extractor.rb index 4ba921569ad7b54b1cf43690ceb0a31f472e420b..8e624215065e7a284a03447d302e3f5307f3fded 100644 --- a/lib/gitlab/closing_issue_extractor.rb +++ b/lib/gitlab/closing_issue_extractor.rb @@ -11,11 +11,13 @@ module Gitlab end def initialize(project, current_user = nil) + @project = project @extractor = Gitlab::ReferenceExtractor.new(project, current_user) end def closed_by_message(message) return [] if message.nil? + return [] unless @project.autoclose_referenced_issues closing_statements = [] message.scan(ISSUE_CLOSING_REGEX) do diff --git a/lib/gitlab/cluster/lifecycle_events.rb b/lib/gitlab/cluster/lifecycle_events.rb index 2b3dc94fc5e0a0a0a19d0ec492eccb6a18ecad41..4ae75e0db0a22dc1072fb7b03e28a6523c7fa85a 100644 --- a/lib/gitlab/cluster/lifecycle_events.rb +++ b/lib/gitlab/cluster/lifecycle_events.rb @@ -149,10 +149,10 @@ module Gitlab def in_clustered_environment? # Sidekiq doesn't fork - return false if Sidekiq.server? + return false if Gitlab::Runtime.sidekiq? # Unicorn always forks - return true if defined?(::Unicorn) + return true if Gitlab::Runtime.unicorn? # Puma sometimes forks return true if in_clustered_puma? @@ -162,7 +162,7 @@ module Gitlab end def in_clustered_puma? - return false unless defined?(::Puma) + return false unless Gitlab::Runtime.puma? @puma_options && @puma_options[:workers] && @puma_options[:workers] > 0 end diff --git a/lib/gitlab/config/entry/attributable.rb b/lib/gitlab/config/entry/attributable.rb index 87bd257f69ab7aab883e0ababc35ce80a84f4d14..4deb233d10eb0928d47ee6a7df2f2543f74776fe 100644 --- a/lib/gitlab/config/entry/attributable.rb +++ b/lib/gitlab/config/entry/attributable.rb @@ -10,7 +10,7 @@ module Gitlab def attributes(*attributes) attributes.flatten.each do |attribute| if method_defined?(attribute) - raise ArgumentError, 'Method already defined!' + raise ArgumentError, "Method already defined: #{attribute}" end define_method(attribute) do diff --git a/lib/gitlab/config/entry/configurable.rb b/lib/gitlab/config/entry/configurable.rb index d5a093a469ad6c31cd2ca00dd4d8668db3db41b8..e7d441bb21c0933d50e5167836cc5c53f82fd8b9 100644 --- a/lib/gitlab/config/entry/configurable.rb +++ b/lib/gitlab/config/entry/configurable.rb @@ -5,7 +5,7 @@ module Gitlab module Entry ## # This mixin is responsible for adding DSL, which purpose is to - # simplifly process of adding child nodes. + # simplify the 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: diff --git a/lib/gitlab/cycle_analytics/production_stage.rb b/lib/gitlab/cycle_analytics/production_stage.rb index 6fd7214dce76bf10179a4a53af679087c7e75f5f..d5f2e868606c840b55dc9d9d64e6afbf3df786e3 100644 --- a/lib/gitlab/cycle_analytics/production_stage.rb +++ b/lib/gitlab/cycle_analytics/production_stage.rb @@ -18,7 +18,7 @@ module Gitlab end def title - s_('CycleAnalyticsStage|Production') + s_('CycleAnalyticsStage|Total') end def legend diff --git a/lib/gitlab/danger/commit_linter.rb b/lib/gitlab/danger/commit_linter.rb new file mode 100644 index 0000000000000000000000000000000000000000..c0748a4b8e6aecae487b630f4c7dc654ab050899 --- /dev/null +++ b/lib/gitlab/danger/commit_linter.rb @@ -0,0 +1,232 @@ +# frozen_string_literal: true + +emoji_checker_path = File.expand_path('emoji_checker', __dir__) +defined?(Rails) ? require_dependency(emoji_checker_path) : require_relative(emoji_checker_path) + +module Gitlab + module Danger + class CommitLinter + MIN_SUBJECT_WORDS_COUNT = 3 + MAX_LINE_LENGTH = 72 + WARN_SUBJECT_LENGTH = 50 + URL_LIMIT_SUBJECT = "https://chris.beams.io/posts/git-commit/#limit-50" + MAX_CHANGED_FILES_IN_COMMIT = 3 + MAX_CHANGED_LINES_IN_COMMIT = 30 + SHORT_REFERENCE_REGEX = %r{([\w\-\/]+)?(#|!|&|%)\d+\b}.freeze + DEFAULT_SUBJECT_DESCRIPTION = 'commit subject' + PROBLEMS = { + subject_too_short: "The %s must contain at least #{MIN_SUBJECT_WORDS_COUNT} words", + subject_too_long: "The %s may not be longer than #{MAX_LINE_LENGTH} characters", + subject_above_warning: "The %s length is acceptable, but please try to [reduce it to #{WARN_SUBJECT_LENGTH} characters](#{URL_LIMIT_SUBJECT}).", + subject_starts_with_lowercase: "The %s must start with a capital letter", + subject_ends_with_a_period: "The %s must not end with a period", + separator_missing: "The commit subject and body must be separated by a blank line", + details_too_many_changes: "Commits that change #{MAX_CHANGED_LINES_IN_COMMIT} or more lines across " \ + "at least #{MAX_CHANGED_FILES_IN_COMMIT} files must describe these changes in the commit body", + details_line_too_long: "The commit body should not contain more than #{MAX_LINE_LENGTH} characters per line", + message_contains_text_emoji: "Avoid the use of Markdown Emoji such as `:+1:`. These add limited value " \ + "to the commit message, and are displayed as plain text outside of GitLab", + message_contains_unicode_emoji: "Avoid the use of Unicode Emoji. These add no value to the commit " \ + "message, and may not be displayed properly everywhere", + message_contains_short_reference: "Use full URLs instead of short references (`gitlab-org/gitlab#123` or " \ + "`!123`), as short references are displayed as plain text outside of GitLab" + }.freeze + + attr_reader :commit, :problems + + def initialize(commit) + @commit = commit + @problems = {} + @linted = false + end + + def fixup? + commit.message.start_with?('fixup!', 'squash!') + end + + def suggestion? + commit.message.start_with?('Apply suggestion to') + end + + def merge? + commit.message.start_with?('Merge branch') + end + + def revert? + commit.message.start_with?('Revert "') + end + + def multi_line? + !details.nil? && !details.empty? + end + + def failed? + problems.any? + end + + def add_problem(problem_key, *args) + @problems[problem_key] = sprintf(PROBLEMS[problem_key], *args) + end + + def lint(subject_description = "commit subject") + return self if @linted + + @linted = true + lint_subject(subject_description) + lint_separator + lint_details + lint_message + + self + end + + def lint_subject(subject_description) + if subject_too_short? + add_problem(:subject_too_short, subject_description) + end + + if subject_too_long? + add_problem(:subject_too_long, subject_description) + elsif subject_above_warning? + add_problem(:subject_above_warning, subject_description) + end + + if subject_starts_with_lowercase? + add_problem(:subject_starts_with_lowercase, subject_description) + end + + if subject_ends_with_a_period? + add_problem(:subject_ends_with_a_period, subject_description) + end + + self + end + + private + + def lint_separator + return self unless separator && !separator.empty? + + add_problem(:separator_missing) + + self + end + + def lint_details + if !multi_line? && many_changes? + add_problem(:details_too_many_changes) + end + + details&.each_line do |line| + line = line.strip + + next unless line_too_long?(line) + + url_size = line.scan(%r((https?://\S+))).sum { |(url)| url.length } # rubocop:disable CodeReuse/ActiveRecord + + # If the line includes a URL, we'll allow it to exceed MAX_LINE_LENGTH characters, but + # only if the line _without_ the URL does not exceed this limit. + next unless line_too_long?(line.length - url_size) + + add_problem(:details_line_too_long) + break + end + + self + end + + def lint_message + if message_contains_text_emoji? + add_problem(:message_contains_text_emoji) + end + + if message_contains_unicode_emoji? + add_problem(:message_contains_unicode_emoji) + end + + if message_contains_short_reference? + add_problem(:message_contains_short_reference) + end + + self + end + + def files_changed + commit.diff_parent.stats[:total][:files] + end + + def lines_changed + commit.diff_parent.stats[:total][:lines] + end + + def many_changes? + files_changed > MAX_CHANGED_FILES_IN_COMMIT && lines_changed > MAX_CHANGED_LINES_IN_COMMIT + end + + def subject + message_parts[0] + end + + def separator + message_parts[1] + end + + def details + message_parts[2] + end + + def line_too_long?(line) + case line + when String + line.length > MAX_LINE_LENGTH + when Integer + line > MAX_LINE_LENGTH + else + raise ArgumentError, "The line argument (#{line}) should be a String or an Integer! #{line.class} given." + end + end + + def subject_too_short? + subject.split(' ').length < MIN_SUBJECT_WORDS_COUNT + end + + def subject_too_long? + line_too_long?(subject) + end + + def subject_above_warning? + subject.length > WARN_SUBJECT_LENGTH + end + + def subject_starts_with_lowercase? + first_char = subject[0] + + first_char.downcase == first_char + end + + def subject_ends_with_a_period? + subject.end_with?('.') + end + + def message_contains_text_emoji? + emoji_checker.includes_text_emoji?(commit.message) + end + + def message_contains_unicode_emoji? + emoji_checker.includes_unicode_emoji?(commit.message) + end + + def message_contains_short_reference? + commit.message.match?(SHORT_REFERENCE_REGEX) + end + + def emoji_checker + @emoji_checker ||= Gitlab::Danger::EmojiChecker.new + end + + def message_parts + @message_parts ||= commit.message.split("\n", 3) + end + end + end +end diff --git a/lib/gitlab/danger/emoji_checker.rb b/lib/gitlab/danger/emoji_checker.rb new file mode 100644 index 0000000000000000000000000000000000000000..e31a6ae5011e40daa36270979b97cfcbe06e4f68 --- /dev/null +++ b/lib/gitlab/danger/emoji_checker.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require 'json' + +module Gitlab + module Danger + class EmojiChecker + DIGESTS = File.expand_path('../../../fixtures/emojis/digests.json', __dir__) + ALIASES = File.expand_path('../../../fixtures/emojis/aliases.json', __dir__) + + # A regex that indicates a piece of text _might_ include an Emoji. The regex + # alone is not enough, as we'd match `:foo:bar:baz`. Instead, we use this + # regex to save us from having to check for all possible emoji names when we + # know one definitely is not included. + LIKELY_EMOJI = /:[\+a-z0-9_\-]+:/.freeze + + UNICODE_EMOJI_REGEX = %r{( + [\u{1F300}-\u{1F5FF}] | + [\u{1F1E6}-\u{1F1FF}] | + [\u{2700}-\u{27BF}] | + [\u{1F900}-\u{1F9FF}] | + [\u{1F600}-\u{1F64F}] | + [\u{1F680}-\u{1F6FF}] | + [\u{2600}-\u{26FF}] + )}x.freeze + + def initialize + names = JSON.parse(File.read(DIGESTS)).keys + + JSON.parse(File.read(ALIASES)).keys + + @emoji = names.map { |name| ":#{name}:" } + end + + def includes_text_emoji?(text) + return false unless text.match?(LIKELY_EMOJI) + + @emoji.any? { |emoji| text.include?(emoji) } + end + + def includes_unicode_emoji?(text) + text.match?(UNICODE_EMOJI_REGEX) + end + end + end +end diff --git a/lib/gitlab/danger/helper.rb b/lib/gitlab/danger/helper.rb index 90cef384a1bafd8dfdd0eed7b10c76ef34c233be..5363533ace57d5e587fa8615718ceb98ae1dc93d 100644 --- a/lib/gitlab/danger/helper.rb +++ b/lib/gitlab/danger/helper.rb @@ -174,6 +174,10 @@ module Gitlab labels - current_mr_labels end + def sanitize_mr_title(title) + title.gsub(/^WIP: */, '').gsub(/`/, '\\\`') + end + def security_mr? return false unless gitlab_helper diff --git a/lib/gitlab/database.rb b/lib/gitlab/database.rb index ceab93228578402599485f93cf8e81cfb26b9ea3..82ec740ade1a87cb17cb88b8c2938cc3115d18fb 100644 --- a/lib/gitlab/database.rb +++ b/lib/gitlab/database.rb @@ -241,6 +241,14 @@ module Gitlab row['version'] end + def self.exists? + connection + + true + rescue + false + end + private_class_method :database_version def self.add_post_migrate_path_to_rails(force: false) diff --git a/lib/gitlab/database/migration_helpers.rb b/lib/gitlab/database/migration_helpers.rb index f9340b262e5ca1bba5e4eb76f4226bf49b7958b1..b7d510c19f914102a5e59259c755b99f57d76380 100644 --- a/lib/gitlab/database/migration_helpers.rb +++ b/lib/gitlab/database/migration_helpers.rb @@ -158,7 +158,7 @@ module Gitlab # name - The name of the foreign key. # # rubocop:disable Gitlab/RailsLogger - def add_concurrent_foreign_key(source, target, column:, on_delete: :cascade, name: nil) + def add_concurrent_foreign_key(source, target, column:, on_delete: :cascade, name: nil, validate: true) # Transactions would result in ALTER TABLE locks being held for the # duration of the transaction, defeating the purpose of this method. if transaction_open? @@ -197,14 +197,32 @@ module Gitlab # Validate the existing constraint. This can potentially take a very # long time to complete, but fortunately does not lock the source table # while running. + # Disable this check by passing `validate: false` to the method call + # The check will be enforced for new data (inserts) coming in, + # but validating existing data is delayed. # # Note this is a no-op in case the constraint is VALID already - disable_statement_timeout do - execute("ALTER TABLE #{source} VALIDATE CONSTRAINT #{options[:name]};") + + if validate + disable_statement_timeout do + execute("ALTER TABLE #{source} VALIDATE CONSTRAINT #{options[:name]};") + end end end # rubocop:enable Gitlab/RailsLogger + def validate_foreign_key(source, column, name: nil) + fk_name = name || concurrent_foreign_key_name(source, column) + + unless foreign_key_exists?(source, name: fk_name) + raise "cannot find #{fk_name} on #{source} table" + end + + disable_statement_timeout do + execute("ALTER TABLE #{source} VALIDATE CONSTRAINT #{fk_name};") + end + end + def foreign_key_exists?(source, target = nil, **options) foreign_keys(source).any? do |foreign_key| tables_match?(target.to_s, foreign_key.to_table.to_s) && diff --git a/lib/gitlab/database_importers/instance_administrators/create_group.rb b/lib/gitlab/database_importers/instance_administrators/create_group.rb new file mode 100644 index 0000000000000000000000000000000000000000..5bf0e5a320dda3fce354704acd4593a9cf2e58b7 --- /dev/null +++ b/lib/gitlab/database_importers/instance_administrators/create_group.rb @@ -0,0 +1,131 @@ +# frozen_string_literal: true + +module Gitlab + module DatabaseImporters + module InstanceAdministrators + class CreateGroup < ::BaseService + include Stepable + + VISIBILITY_LEVEL = Gitlab::VisibilityLevel::INTERNAL + + steps :validate_application_settings, + :validate_admins, + :create_group, + :save_group_id, + :add_group_members, + :track_event + + def initialize + super(nil) + end + + def execute + execute_steps + end + + private + + def validate_application_settings(result) + return success(result) if application_settings + + log_error('No application_settings found') + error(_('No application_settings found')) + end + + def validate_admins(result) + unless instance_admins.any? + log_error('No active admin user found') + return error(_('No active admin user found')) + end + + success(result) + end + + def create_group(result) + if group_created? + log_info(_('Instance administrators group already exists')) + result[:group] = instance_administrators_group + return success(result) + end + + result[:group] = ::Groups::CreateService.new(instance_admins.first, create_group_params).execute + + if result[:group].persisted? + success(result) + else + log_error("Could not create instance administrators group. Errors: %{errors}" % { errors: result[:group].errors.full_messages }) + error(_('Could not create group')) + end + end + + def save_group_id(result) + return success(result) if group_created? + + response = application_settings.update( + instance_administrators_group_id: result[:group].id + ) + + if response + success(result) + else + log_error("Could not save instance administrators group ID, errors: %{errors}" % { errors: application_settings.errors.full_messages }) + error(_('Could not save group ID')) + end + end + + def add_group_members(result) + group = result[:group] + members = group.add_users(members_to_add(group), Gitlab::Access::MAINTAINER) + errors = members.flat_map { |member| member.errors.full_messages } + + if errors.any? + log_error('Could not add admins as members to self-monitoring project. Errors: %{errors}' % { errors: errors }) + error(_('Could not add admins as members')) + else + success(result) + end + end + + def track_event(result) + ::Gitlab::Tracking.event("instance_administrators_group", "group_created") + + success(result) + end + + def group_created? + instance_administrators_group.present? + end + + def application_settings + @application_settings ||= ApplicationSetting.current_without_cache + end + + def instance_administrators_group + application_settings.instance_administrators_group + end + + def instance_admins + @instance_admins ||= User.admins.active + end + + def members_to_add(group) + # Exclude admins who are already members of group because + # `group.add_users(users)` returns an error if the users parameter contains + # users who are already members of the group. + instance_admins - group.members.collect(&:user) + end + + def create_group_params + { + name: 'GitLab Instance Administrators', + visibility_level: VISIBILITY_LEVEL, + + # The 8 random characters at the end are so that the path does not + # clash with any existing group that the user might have created. + path: "gitlab-instance-administrators-#{SecureRandom.hex(4)}" + } + end + end + end + end +end diff --git a/lib/gitlab/database_importers/self_monitoring/helpers.rb b/lib/gitlab/database_importers/self_monitoring/helpers.rb new file mode 100644 index 0000000000000000000000000000000000000000..d7e90967e892fa0d4747e4e43047f0c0ae6b866d --- /dev/null +++ b/lib/gitlab/database_importers/self_monitoring/helpers.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Gitlab + module DatabaseImporters + module SelfMonitoring + module Helpers + def application_settings + @application_settings ||= ApplicationSetting.current_without_cache + end + + def project_created? + self_monitoring_project.present? + end + + def self_monitoring_project + application_settings.instance_administration_project + end + + def self_monitoring_project_id + application_settings.instance_administration_project_id + end + end + end + end +end diff --git a/lib/gitlab/database_importers/self_monitoring/project/create_service.rb b/lib/gitlab/database_importers/self_monitoring/project/create_service.rb index fbf252b7ec37e25f0447faf1b4e24a1711326963..d08afeef3b626115a31437be264540c08fd9638a 100644 --- a/lib/gitlab/database_importers/self_monitoring/project/create_service.rb +++ b/lib/gitlab/database_importers/self_monitoring/project/create_service.rb @@ -6,38 +6,24 @@ module Gitlab module Project class CreateService < ::BaseService include Stepable - - STEPS_ALLOWED_TO_FAIL = [ - :validate_application_settings, :validate_project_created, :validate_admins - ].freeze + include SelfMonitoring::Helpers VISIBILITY_LEVEL = Gitlab::VisibilityLevel::INTERNAL PROJECT_NAME = 'GitLab Instance Administration' steps :validate_application_settings, - :validate_project_created, - :validate_admins, :create_group, :create_project, :save_project_id, - :add_group_members, - :add_prometheus_manual_configuration + :add_prometheus_manual_configuration, + :track_event def initialize super(nil) end - def execute! - result = execute_steps - if result[:status] == :success - ::Gitlab::Tracking.event("self_monitoring", "project_created") - result - elsif STEPS_ALLOWED_TO_FAIL.include?(result[:last_step]) - ::Gitlab::Tracking.event("self_monitoring", "project_created") - success - else - raise StandardError, result[:message] - end + def execute + execute_steps end private @@ -49,46 +35,27 @@ module Gitlab error(_('No application_settings found')) end - def validate_project_created(result) - return success(result) unless project_created? - - log_error('Project already created') - error(_('Project already created')) - end - - def validate_admins(result) - unless instance_admins.any? - log_error('No active admin user found') - return error(_('No active admin user found')) - end - - success(result) - end - def create_group(result) - if project_created? - log_info(_('Instance administrators group already exists')) - result[:group] = application_settings.instance_administration_project.owner - return success(result) - end + create_group_response = + Gitlab::DatabaseImporters::InstanceAdministrators::CreateGroup.new.execute - result[:group] = ::Groups::CreateService.new(group_owner, create_group_params).execute - - if result[:group].persisted? - success(result) + if create_group_response[:status] == :success + success(result.merge(create_group_response)) else - error(_('Could not create group')) + error(create_group_response[:message]) end end def create_project(result) if project_created? log_info('Instance administration project already exists') - result[:project] = application_settings.instance_administration_project + result[:project] = self_monitoring_project return success(result) end - result[:project] = ::Projects::CreateService.new(group_owner, create_project_params(result[:group])).execute + owner = result[:group].owners.first + + result[:project] = ::Projects::CreateService.new(owner, create_project_params(result[:group])).execute if result[:project].persisted? success(result) @@ -99,7 +66,7 @@ module Gitlab end def save_project_id(result) - return success if project_created? + return success(result) if project_created? response = application_settings.update( instance_administration_project_id: result[:project].id @@ -113,19 +80,6 @@ module Gitlab end end - def add_group_members(result) - group = result[:group] - members = group.add_users(members_to_add(group), Gitlab::Access::MAINTAINER) - errors = members.flat_map { |member| member.errors.full_messages } - - if errors.any? - log_error('Could not add admins as members to self-monitoring project. Errors: %{errors}' % { errors: errors }) - error(_('Could not add admins as members')) - else - success(result) - end - end - def add_prometheus_manual_configuration(result) return success(result) unless prometheus_enabled? return success(result) unless prometheus_listen_address.present? @@ -140,12 +94,10 @@ module Gitlab success(result) end - def application_settings - @application_settings ||= ApplicationSetting.current_without_cache - end + def track_event(result) + ::Gitlab::Tracking.event("self_monitoring", "project_created") - def project_created? - application_settings.instance_administration_project.present? + success(result) end def parse_url(uri_string) @@ -161,29 +113,6 @@ module Gitlab ::Gitlab::Prometheus::Internal.listen_address end - def instance_admins - @instance_admins ||= User.admins.active - end - - def group_owner - instance_admins.first - end - - def members_to_add(group) - # Exclude admins who are already members of group because - # `group.add_users(users)` returns an error if the users parameter contains - # users who are already members of the group. - instance_admins - group.members.collect(&:user) - end - - def create_group_params - { - name: 'GitLab Instance Administrators', - path: "gitlab-instance-administrators-#{SecureRandom.hex(4)}", - visibility_level: VISIBILITY_LEVEL - } - end - def docs_path Rails.application.routes.url_helpers.help_page_path( 'administration/monitoring/gitlab_instance_administration_project/index' diff --git a/lib/gitlab/database_importers/self_monitoring/project/delete_service.rb b/lib/gitlab/database_importers/self_monitoring/project/delete_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..998977b4000c41634aebac27957b12851a395967 --- /dev/null +++ b/lib/gitlab/database_importers/self_monitoring/project/delete_service.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Gitlab + module DatabaseImporters + module SelfMonitoring + module Project + class DeleteService < ::BaseService + include Stepable + include SelfMonitoring::Helpers + + steps :validate_self_monitoring_project_exists, + :destroy_project + + def initialize + super(nil) + end + + def execute + execute_steps + end + + private + + def validate_self_monitoring_project_exists(result) + unless project_created? || self_monitoring_project_id.present? + return error(_('Self monitoring project does not exist')) + end + + success(result) + end + + def destroy_project(result) + return success(result) unless project_created? + + if self_monitoring_project.destroy + success(result) + else + log_error(self_monitoring_project.errors.full_messages) + error(_('Error deleting project. Check logs for error details.')) + end + end + end + end + end + end +end diff --git a/lib/gitlab/dependency_linker.rb b/lib/gitlab/dependency_linker.rb index c63d9e5bb71758f6bfa6640173e88446b1a14758..7af380689d5a68d996b213e897e369de7d456058 100644 --- a/lib/gitlab/dependency_linker.rb +++ b/lib/gitlab/dependency_linker.rb @@ -12,7 +12,8 @@ module Gitlab PodspecJsonLinker, CartfileLinker, GodepsJsonLinker, - RequirementsTxtLinker + RequirementsTxtLinker, + CargoTomlLinker ].freeze def self.linker(blob_name) diff --git a/lib/gitlab/dependency_linker/cargo_toml_linker.rb b/lib/gitlab/dependency_linker/cargo_toml_linker.rb new file mode 100644 index 0000000000000000000000000000000000000000..57e0a5f4699b67df0bd478a8aae99033c6b2faf7 --- /dev/null +++ b/lib/gitlab/dependency_linker/cargo_toml_linker.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Gitlab + module DependencyLinker + class CargoTomlLinker < BaseLinker + self.file_type = :cargo_toml + + def link + return highlighted_text unless toml + + super + end + + private + + def link_dependencies + link_dependencies_at("dependencies") + link_dependencies_at("dev-dependencies") + link_dependencies_at("build-dependencies") + end + + def link_dependencies_at(type) + dependencies = toml[type] + return unless dependencies + + dependencies.each do |name, value| + link_toml(name, value, type) do |name| + "https://crates.io/crates/#{name}" + end + end + end + + def link_toml(key, value, type, &url_proc) + if value.is_a? String + link_regex(/^(?<name>#{key})\s*=\s*"#{value}"/, &url_proc) + else + link_regex(/^\[#{type}\.(?<name>#{key})]/, &url_proc) + end + end + + def toml + @toml ||= TomlRB.parse(plain_text) rescue nil + end + end + end +end diff --git a/lib/gitlab/diff/file.rb b/lib/gitlab/diff/file.rb index 30fe7440148bda6d48cd7eef57d11f1d7a54c5fa..2ba38f317202f34d446f5639727cd075c376d938 100644 --- a/lib/gitlab/diff/file.rb +++ b/lib/gitlab/diff/file.rb @@ -358,6 +358,10 @@ module Gitlab end end + def modified_file? + new_file? || deleted_file? || content_changed? + end + # We can't use Object#try because Blob doesn't inherit from Object, but # from BasicObject (via SimpleDelegator). def try_blobs(meth) @@ -393,33 +397,16 @@ module Gitlab end def simple_viewer_class + return DiffViewer::Collapsed if collapsed? return DiffViewer::NotDiffable unless diffable? + return DiffViewer::Text if modified_file? && text? + return DiffViewer::NoPreview if content_changed? + return DiffViewer::Added if new_file? + return DiffViewer::Deleted if deleted_file? + return DiffViewer::Renamed if renamed_file? + return DiffViewer::ModeChanged if mode_changed? - if content_changed? - if text? - DiffViewer::Text - else - DiffViewer::NoPreview - end - elsif new_file? - if text? - DiffViewer::Text - else - DiffViewer::Added - end - elsif deleted_file? - if text? - DiffViewer::Text - else - DiffViewer::Deleted - end - elsif renamed_file? - DiffViewer::Renamed - elsif mode_changed? - DiffViewer::ModeChanged - else - DiffViewer::NoPreview - end + DiffViewer::NoPreview end def rich_viewer_class @@ -427,8 +414,9 @@ module Gitlab end def viewer_class_from(classes) + return if collapsed? return unless diffable? - return unless new_file? || deleted_file? || content_changed? + return unless modified_file? return if different_type? || external_storage_error? verify_binary = !stored_externally? diff --git a/lib/gitlab/diff/file_collection/merge_request_diff_base.rb b/lib/gitlab/diff/file_collection/merge_request_diff_base.rb index 06cf3d4d168752fe3d18fcf6c9eb8c0e256e60f5..d27da186de06789dacfa090cff78e62ed7820dd7 100644 --- a/lib/gitlab/diff/file_collection/merge_request_diff_base.rb +++ b/lib/gitlab/diff/file_collection/merge_request_diff_base.rb @@ -47,7 +47,7 @@ module Gitlab private def cache - @cache ||= if Feature.enabled?(:hset_redis_diff_caching, project) + @cache ||= if Feature.enabled?(:hset_redis_diff_caching, project, default_enabled: true) Gitlab::Diff::HighlightCache.new(self) else Gitlab::Diff::DeprecatedHighlightCache.new(self) diff --git a/lib/gitlab/email/attachment_uploader.rb b/lib/gitlab/email/attachment_uploader.rb index 3323ce60158c08021664e6c044e901a9644251d7..0a14a909e311e8fc1453e8019f47e51e1c3cd96d 100644 --- a/lib/gitlab/email/attachment_uploader.rb +++ b/lib/gitlab/email/attachment_uploader.rb @@ -9,7 +9,7 @@ module Gitlab @message = message end - def execute(project) + def execute(upload_parent:, uploader_class:) attachments = [] message.attachments.each do |attachment| @@ -23,7 +23,7 @@ module Gitlab content_type: attachment.content_type } - uploader = UploadService.new(project, file).execute + uploader = UploadService.new(upload_parent, file, uploader_class).execute attachments << uploader.to_h if uploader ensure tmp.close! diff --git a/lib/gitlab/email/handler/reply_processing.rb b/lib/gitlab/email/handler/reply_processing.rb index d8f4be8ada1c70e22acef14c35680a578b820300..312a9fdfbae6a342438774a7090856baa2f91602 100644 --- a/lib/gitlab/email/handler/reply_processing.rb +++ b/lib/gitlab/email/handler/reply_processing.rb @@ -41,13 +41,20 @@ module Gitlab end def add_attachments(reply) - attachments = Email::AttachmentUploader.new(mail).execute(project) + attachments = Email::AttachmentUploader.new(mail).execute(upload_params) reply + attachments.map do |link| "\n\n#{link[:markdown]}" end.join end + def upload_params + { + upload_parent: project, + uploader_class: FileUploader + } + end + def validate_permission!(permission) raise UserNotFoundError unless author raise UserBlockedError if author.blocked? @@ -79,3 +86,5 @@ module Gitlab end end end + +Gitlab::Email::Handler::ReplyProcessing.prepend_if_ee('::EE::Gitlab::Email::Handler::ReplyProcessing') diff --git a/lib/gitlab/email/receiver.rb b/lib/gitlab/email/receiver.rb index 847260b2e0f79dc9bfdef558242bf948f8ac0eb3..f028102da9b86bdffa7ca619f12cedd0b426b45c 100644 --- a/lib/gitlab/email/receiver.rb +++ b/lib/gitlab/email/receiver.rb @@ -66,7 +66,8 @@ module Gitlab def key_from_additional_headers(mail) find_key_from_references(mail) || - find_key_from_delivered_to_header(mail) + find_key_from_delivered_to_header(mail) || + find_key_from_envelope_to_header(mail) end def ensure_references_array(references) @@ -96,6 +97,13 @@ module Gitlab end end + def find_key_from_envelope_to_header(mail) + Array(mail[:envelope_to]).find do |header| + key = Gitlab::IncomingEmail.key_from_address(header.value) + break key if key + end + end + def ignore_auto_reply!(mail) if auto_submitted?(mail) || auto_replied?(mail) raise AutoGeneratedEmailError diff --git a/lib/gitlab/error_tracking/detailed_error.rb b/lib/gitlab/error_tracking/detailed_error.rb index 169d6c03f12b2ce2cf3d2a28240f249ade13618a..c240ec1fa4f0a923c84d2c68a824bc47786057c8 100644 --- a/lib/gitlab/error_tracking/detailed_error.rb +++ b/lib/gitlab/error_tracking/detailed_error.rb @@ -12,10 +12,13 @@ module Gitlab :external_url, :first_release_last_commit, :first_release_short_version, + :first_release_version, :first_seen, :frequency, - :gitlab_project, + :gitlab_commit, + :gitlab_commit_path, :gitlab_issue, + :gitlab_project, :id, :last_release_last_commit, :last_release_short_version, @@ -26,6 +29,7 @@ module Gitlab :project_slug, :short_id, :status, + :tags, :title, :type, :user_count diff --git a/lib/gitlab/error_tracking/repo.rb b/lib/gitlab/error_tracking/repo.rb new file mode 100644 index 0000000000000000000000000000000000000000..50611943bac37079b8d22ddff77e3403fc275cbe --- /dev/null +++ b/lib/gitlab/error_tracking/repo.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Gitlab + module ErrorTracking + class Repo + attr_accessor :status, :integration_id, :project_id + + def initialize(status:, integration_id:, project_id:) + @status = status + @integration_id = integration_id + @project_id = project_id + end + end + end +end diff --git a/lib/gitlab/exception_log_formatter.rb b/lib/gitlab/exception_log_formatter.rb index e0de0219294fbad14ee372b8c8e280104fcb0c13..92d55213cc248b0e25f3142b973bb58d28b89fa6 100644 --- a/lib/gitlab/exception_log_formatter.rb +++ b/lib/gitlab/exception_log_formatter.rb @@ -13,7 +13,7 @@ module Gitlab ) if exception.backtrace - payload['exception.backtrace'] = Gitlab::Profiler.clean_backtrace(exception.backtrace) + payload['exception.backtrace'] = Gitlab::BacktraceCleaner.clean_backtrace(exception.backtrace) end end end diff --git a/lib/gitlab/experimentation.rb b/lib/gitlab/experimentation.rb index 4fbf15d521a2df6bf1b292917076883dea5f0eac..9d14695c0981044c1c7004b5be86e468213c2c68 100644 --- a/lib/gitlab/experimentation.rb +++ b/lib/gitlab/experimentation.rb @@ -16,6 +16,12 @@ module Gitlab environment: ::Gitlab.dev_env_or_com?, enabled_ratio: 1, tracking_category: 'Growth::Acquisition::Experiment::SignUpFlow' + }, + paid_signup_flow: { + feature_toggle: :paid_signup_flow, + environment: ::Gitlab.dev_env_or_com?, + enabled_ratio: 0.1, + tracking_category: 'Growth::Acquisition::Experiment::PaidSignUpFlow' } }.freeze diff --git a/lib/gitlab/file_detector.rb b/lib/gitlab/file_detector.rb index a386c21983d2782cc35df07eb128016b6706a844..305fbeecce1bc4a2cca9925027910d03639fc4d5 100644 --- a/lib/gitlab/file_detector.rb +++ b/lib/gitlab/file_detector.rb @@ -8,7 +8,7 @@ module Gitlab module FileDetector PATTERNS = { # Project files - readme: /\A(#{Regexp.union(*Gitlab::MarkupHelper::PLAIN_FILENAMES).source})(\.(#{Regexp.union(*Gitlab::MarkupHelper::EXTENSIONS).source}))?\z/i, + readme: /\A(#{Regexp.union(*Gitlab::MarkupHelper::PLAIN_FILENAMES).source})(\.(txt|#{Regexp.union(*Gitlab::MarkupHelper::EXTENSIONS).source}))?\z/i, changelog: %r{\A(changelog|history|changes|news)[^/]*\z}i, license: %r{\A((un)?licen[sc]e|copying)(\.[^/]+)?\z}i, contributing: %r{\Acontributing[^/]*\z}i, @@ -25,6 +25,7 @@ module Gitlab route_map: '.gitlab/route-map.yml', # Dependency files + cargo_toml: 'Cargo.toml', cartfile: %r{\ACartfile[^/]*\z}, composer_json: 'composer.json', gemfile: /\A(Gemfile|gems\.rb)\z/, diff --git a/lib/gitlab/file_finder.rb b/lib/gitlab/file_finder.rb index ec9d2df613bd13270dda2040f2bb815bc6c56c15..a71baadfdb33d3ba6b43fa9c6405d5119545b262 100644 --- a/lib/gitlab/file_finder.rb +++ b/lib/gitlab/file_finder.rb @@ -37,7 +37,7 @@ module Gitlab def find_by_path(query) search_paths(query).map do |path| - Gitlab::Search::FoundBlob.new(blob_path: path, project: project, ref: ref, repository: repository) + Gitlab::Search::FoundBlob.new(blob_path: path, path: path, project: project, ref: ref, repository: repository) end end diff --git a/lib/gitlab/plugin.rb b/lib/gitlab/file_hook.rb similarity index 91% rename from lib/gitlab/plugin.rb rename to lib/gitlab/file_hook.rb index b6700f4733b1d027d1b544be9c0bdd637d9ee5c6..f886fd10f536976a5ed00ebe9c81dc853ded59b1 100644 --- a/lib/gitlab/plugin.rb +++ b/lib/gitlab/file_hook.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Gitlab - module Plugin + module FileHook def self.any? plugin_glob.any? { |entry| File.file?(entry) } end @@ -17,7 +17,7 @@ module Gitlab def self.execute_all_async(data) args = files.map { |file| [file, data] } - PluginWorker.bulk_perform_async(args) + FileHookWorker.bulk_perform_async(args) end def self.execute(file, data) diff --git a/lib/gitlab/plugin_logger.rb b/lib/gitlab/file_hook_logger.rb similarity index 72% rename from lib/gitlab/plugin_logger.rb rename to lib/gitlab/file_hook_logger.rb index df3bd56fd2f1ca6f1f92c9c9b9beb53989978c1a..c5e69172016b02f70f72d29de4ec7870d16c18b1 100644 --- a/lib/gitlab/plugin_logger.rb +++ b/lib/gitlab/file_hook_logger.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Gitlab - class PluginLogger < Gitlab::Logger + class FileHookLogger < Gitlab::Logger def self.file_name_noext 'plugin' end diff --git a/lib/gitlab/git.rb b/lib/gitlab/git.rb index 8d13c74dca2531065e57f979b2adb822b19103c1..b6bffb11344f7a12f6c52cbc4d1c9e3485bfc8c4 100644 --- a/lib/gitlab/git.rb +++ b/lib/gitlab/git.rb @@ -35,16 +35,6 @@ module Gitlab end end - def committer_hash(email:, name:) - return if email.nil? || name.nil? - - { - email: email, - name: name, - time: Time.now - } - end - def tag_name(ref) ref = ref.to_s if self.tag_ref?(ref) @@ -88,6 +78,7 @@ module Gitlab end def shas_eql?(sha1, sha2) + return true if sha1.nil? && sha2.nil? return false if sha1.nil? || sha2.nil? return false unless sha1.class == sha2.class diff --git a/lib/gitlab/git/commit.rb b/lib/gitlab/git/commit.rb index b2dc9a8a3c8ef7131ef57f50c92213a3d39f1d45..48da838366fa8c2992b80d8ff20f1d46557fa73e 100644 --- a/lib/gitlab/git/commit.rb +++ b/lib/gitlab/git/commit.rb @@ -18,7 +18,7 @@ module Gitlab :committed_date, :committer_name, :committer_email ].freeze - attr_accessor *SERIALIZE_KEYS # rubocop:disable Lint/AmbiguousOperator + attr_accessor(*SERIALIZE_KEYS) def ==(other) return false unless other.is_a?(Gitlab::Git::Commit) @@ -254,7 +254,7 @@ module Gitlab end def no_commit_message - "--no commit message" + "No commit message" end def to_hash diff --git a/lib/gitlab/git/gitmodules_parser.rb b/lib/gitlab/git/gitmodules_parser.rb index 575e12390cdc8851a687c23f4dc1b70941752f5f..92940c352d369ab54e77084a8b8c570af239eaa3 100644 --- a/lib/gitlab/git/gitmodules_parser.rb +++ b/lib/gitlab/git/gitmodules_parser.rb @@ -71,7 +71,7 @@ module Gitlab # Convert from an indexed by name to an array indexed by path # If a submodule doesn't have a path, it is considered bogus # and is ignored - submodules_by_name.each_with_object({}) do |(name, data), results| + submodules_by_name.each_with_object({}) do |(_name, data), results| path = data.delete 'path' next unless path diff --git a/lib/gitlab/git/repository.rb b/lib/gitlab/git/repository.rb index 4971a18e270800c3e022365d0b4e3b49e083773c..ed3e7a1e39c5cb82f1f4974f37df8145546bd605 100644 --- a/lib/gitlab/git/repository.rb +++ b/lib/gitlab/git/repository.rb @@ -853,7 +853,7 @@ module Gitlab end end - def rebase(user, rebase_id, branch:, branch_sha:, remote_repository:, remote_branch:, &block) + def rebase(user, rebase_id, branch:, branch_sha:, remote_repository:, remote_branch:, push_options: [], &block) wrapped_gitaly_errors do gitaly_operation_client.rebase( user, @@ -862,6 +862,7 @@ module Gitlab branch_sha: branch_sha, remote_repository: remote_repository, remote_branch: remote_branch, + push_options: push_options, &block ) end diff --git a/lib/gitlab/git/rugged_impl/use_rugged.rb b/lib/gitlab/git/rugged_impl/use_rugged.rb index 80b75689334eaa3fd5a62d8f129bd6bc3ad04660..068aaf03c5175c0e69234e40600baf39f34fe682 100644 --- a/lib/gitlab/git/rugged_impl/use_rugged.rb +++ b/lib/gitlab/git/rugged_impl/use_rugged.rb @@ -8,9 +8,17 @@ module Gitlab feature = Feature.get(feature_key) return feature.enabled? if Feature.persisted?(feature) + # Disable Rugged auto-detect(can_use_disk?) when Puma threads>1 + # https://gitlab.com/gitlab-org/gitlab/issues/119326 + return false if running_puma_with_multiple_threads? + Gitlab::GitalyClient.can_use_disk?(repo.storage) end + def running_puma_with_multiple_threads? + Gitlab::Runtime.puma? && ::Puma.cli_config.options[:max_threads] > 1 + end + def execute_rugged_call(method_name, *args) Gitlab::GitalyClient::StorageSettings.allow_disk_access do start = Gitlab::Metrics::System.monotonic_time @@ -27,7 +35,7 @@ module Gitlab feature: method_name, args: args, duration: duration, - backtrace: Gitlab::Profiler.clean_backtrace(caller)) + backtrace: Gitlab::BacktraceCleaner.clean_backtrace(caller)) end result diff --git a/lib/gitlab/git/tag.rb b/lib/gitlab/git/tag.rb index 0218f6e6232b34cd6046b21679f4c1391f057c25..08dbd52e3fb3938213a6027b47d79b021c96d3ae 100644 --- a/lib/gitlab/git/tag.rb +++ b/lib/gitlab/git/tag.rb @@ -10,7 +10,7 @@ module Gitlab MAX_TAG_MESSAGE_DISPLAY_SIZE = 10.megabytes SERIALIZE_KEYS = %i[name target target_commit message].freeze - attr_accessor *SERIALIZE_KEYS # rubocop:disable Lint/AmbiguousOperator + attr_accessor(*SERIALIZE_KEYS) class << self def get_message(repository, tag_id) diff --git a/lib/gitlab/gitaly_client.rb b/lib/gitlab/gitaly_client.rb index 9e033c705bd09fe088abaaa216e930b76bff683d..262a1ef653ff74dcc21a6eb112e4ac648ecc1905 100644 --- a/lib/gitlab/gitaly_client.rb +++ b/lib/gitlab/gitaly_client.rb @@ -29,7 +29,7 @@ module Gitlab PEM_REGEX = /\-+BEGIN CERTIFICATE\-+.+?\-+END CERTIFICATE\-+/m.freeze SERVER_VERSION_FILE = 'GITALY_SERVER_VERSION' MAXIMUM_GITALY_CALLS = 30 - CLIENT_NAME = (Sidekiq.server? ? 'gitlab-sidekiq' : 'gitlab-web').freeze + CLIENT_NAME = (Gitlab::Runtime.sidekiq? ? 'gitlab-sidekiq' : 'gitlab-web').freeze GITALY_METADATA_FILENAME = '.gitaly-metadata' MUTEX = Mutex.new @@ -160,6 +160,7 @@ module Gitlab def self.execute(storage, service, rpc, request, remote_storage:, timeout:) enforce_gitaly_request_limits(:call) + Gitlab::RequestContext.instance.ensure_deadline_not_exceeded! kwargs = request_kwargs(storage, timeout: timeout.to_f, remote_storage: remote_storage) kwargs = yield(kwargs) if block_given? @@ -179,7 +180,7 @@ module Gitlab self.query_time += duration if Gitlab::PerformanceBar.enabled_for_request? add_call_details(feature: "#{service}##{rpc}", duration: duration, request: request_hash, rpc: rpc, - backtrace: Gitlab::Profiler.clean_backtrace(caller)) + backtrace: Gitlab::BacktraceCleaner.clean_backtrace(caller)) end end @@ -234,12 +235,28 @@ module Gitlab metadata['gitaly-session-id'] = session_id metadata.merge!(Feature::Gitaly.server_feature_flags) - result = { metadata: metadata } + deadline_info = request_deadline(timeout) + metadata.merge!(deadline_info.slice(:deadline_type)) - result[:deadline] = real_time + timeout if timeout > 0 - result + { metadata: metadata, deadline: deadline_info[:deadline] } end + def self.request_deadline(timeout) + # timeout being 0 means the request is allowed to run indefinitely. + # We can't allow that inside a request, but this won't count towards Gitaly + # error budgets + regular_deadline = real_time.to_i + timeout if timeout > 0 + + return { deadline: regular_deadline } if Sidekiq.server? + return { deadline: regular_deadline } unless Gitlab::RequestContext.instance.request_deadline + + limited_deadline = [regular_deadline, Gitlab::RequestContext.instance.request_deadline].compact.min + limited = limited_deadline < regular_deadline + + { deadline: limited_deadline, deadline_type: limited ? "limited" : "regular" } + end + private_class_method :request_deadline + def self.session_id Gitlab::SafeRequestStore[:gitaly_session_id] ||= SecureRandom.uuid end @@ -382,17 +399,13 @@ module Gitlab end def self.long_timeout - if web_app_server? + if Gitlab::Runtime.web_server? default_timeout else 6.hours end end - def self.web_app_server? - defined?(::Unicorn) || defined?(::Puma) - end - def self.storage_metadata_file_path(storage) Gitlab::GitalyClient::StorageSettings.allow_disk_access do File.join( @@ -442,7 +455,7 @@ module Gitlab def self.count_stack return unless Gitlab::SafeRequestStore.active? - stack_string = Gitlab::Profiler.clean_backtrace(caller).drop(1).join("\n") + stack_string = Gitlab::BacktraceCleaner.clean_backtrace(caller).drop(1).join("\n") Gitlab::SafeRequestStore[:stack_counter] ||= Hash.new diff --git a/lib/gitlab/gitaly_client/operation_service.rb b/lib/gitlab/gitaly_client/operation_service.rb index 61c5db4c4df7946a3bc76ed93a5edb79b64a74e0..27522f89a5b798e8db47804e5bb48acdbf30ac26 100644 --- a/lib/gitlab/gitaly_client/operation_service.rb +++ b/lib/gitlab/gitaly_client/operation_service.rb @@ -233,7 +233,7 @@ module Gitlab end end - def rebase(user, rebase_id, branch:, branch_sha:, remote_repository:, remote_branch:) + def rebase(user, rebase_id, branch:, branch_sha:, remote_repository:, remote_branch:, push_options: []) request_enum = QueueEnumerator.new rebase_sha = nil @@ -256,7 +256,8 @@ module Gitlab branch: encode_binary(branch), branch_sha: branch_sha, remote_repository: remote_repository.gitaly_repository, - remote_branch: encode_binary(remote_branch) + remote_branch: encode_binary(remote_branch), + git_push_options: push_options ) ) ) diff --git a/lib/gitlab/github_import/client.rb b/lib/gitlab/github_import/client.rb index 826b35d685cccf5f2cf90c0a979efff08737885c..22803c5cd71ad514d54c8c7fd22cc538cb176a90 100644 --- a/lib/gitlab/github_import/client.rb +++ b/lib/gitlab/github_import/client.rb @@ -189,7 +189,7 @@ module Gitlab end def default_api_endpoint - OmniAuth::Strategies::GitHub.default_options[:client_options][:site] + OmniAuth::Strategies::GitHub.default_options[:client_options][:site] || ::Octokit::Default.api_endpoint end def verify_ssl diff --git a/lib/gitlab/github_import/importer/pull_request_importer.rb b/lib/gitlab/github_import/importer/pull_request_importer.rb index 6d2aff63a4753b0be22cdd6ab7225c7554f88c9b..f09e0bd980633dde44fa8f88b3eed5267fddd20c 100644 --- a/lib/gitlab/github_import/importer/pull_request_importer.rb +++ b/lib/gitlab/github_import/importer/pull_request_importer.rb @@ -27,6 +27,7 @@ module Gitlab mr, already_exists = create_merge_request if mr + set_merge_request_assignees(mr) insert_git_data(mr, already_exists) issuable_finder.cache_database_id(mr.id) end @@ -57,7 +58,6 @@ module Gitlab state_id: ::MergeRequest.available_states[pull_request.state], milestone_id: milestone_finder.id_for(pull_request), author_id: author_id, - assignee_id: user_finder.assignee_id_for(pull_request), created_at: pull_request.created_at, updated_at: pull_request.updated_at } @@ -65,6 +65,10 @@ module Gitlab create_merge_request_without_hooks(project, attributes, pull_request.iid) end + def set_merge_request_assignees(merge_request) + merge_request.assignee_ids = [user_finder.assignee_id_for(pull_request)] + end + def insert_git_data(merge_request, already_exists) insert_or_replace_git_data(merge_request, pull_request.source_branch_sha, pull_request.target_branch_sha, already_exists) # We need to create the branch after the merge request is diff --git a/lib/gitlab/gon_helper.rb b/lib/gitlab/gon_helper.rb index f22c69c531a791d1db0745b4436fd4a2a26e2b2e..2e27e954e79fe0d958ea3d1f1eb8b7bbe567b1aa 100644 --- a/lib/gitlab/gon_helper.rb +++ b/lib/gitlab/gon_helper.rb @@ -28,6 +28,7 @@ module Gitlab gon.sprite_file_icons = IconsHelper.sprite_file_icons_path gon.emoji_sprites_css_path = ActionController::Base.helpers.stylesheet_path('emoji_sprites') gon.test_env = Rails.env.test? + gon.disable_animations = Gitlab.config.gitlab['disable_animations'] gon.suggested_label_colors = LabelsHelper.suggested_colors gon.first_day_of_week = current_user&.first_day_of_week || Gitlab::CurrentSettings.first_day_of_week gon.ee = Gitlab.ee? diff --git a/lib/gitlab/gpg.rb b/lib/gitlab/gpg.rb index e3c474bc0fe6ae70dc584417a7ef04a4adab1f52..7e6f6a519a6a2d93ba2152cc8bff09df97f3cb27 100644 --- a/lib/gitlab/gpg.rb +++ b/lib/gitlab/gpg.rb @@ -135,7 +135,7 @@ module Gitlab end def cleanup_time - Sidekiq.server? ? BG_CLEANUP_RUNTIME_S : FG_CLEANUP_RUNTIME_S + Gitlab::Runtime.sidekiq? ? BG_CLEANUP_RUNTIME_S : FG_CLEANUP_RUNTIME_S end def tmp_keychains_created diff --git a/lib/gitlab/graphql/authorize/authorize_resource.rb b/lib/gitlab/graphql/authorize/authorize_resource.rb index 26e8c53032fe3fffd1e5ab4b8bbc889c14c826e3..94871498cf89ba47eb16eedd298ab1d272e58ac3 100644 --- a/lib/gitlab/graphql/authorize/authorize_resource.rb +++ b/lib/gitlab/graphql/authorize/authorize_resource.rb @@ -40,7 +40,7 @@ module Gitlab def authorize!(object) unless authorized_resource?(object) - raise_resource_not_avaiable_error! + raise_resource_not_available_error! end end @@ -63,7 +63,7 @@ module Gitlab end end - def raise_resource_not_avaiable_error! + def raise_resource_not_available_error! raise Gitlab::Graphql::Errors::ResourceNotAvailable, RESOURCE_ACCESS_ERROR end end diff --git a/lib/gitlab/graphql/connections.rb b/lib/gitlab/graphql/connections.rb index 38c7d98f37ce1645ddaae01134e21b3e820fde46..08d5cd0b72eceff017db442ba3c1ef86165e06fa 100644 --- a/lib/gitlab/graphql/connections.rb +++ b/lib/gitlab/graphql/connections.rb @@ -12,6 +12,10 @@ module Gitlab Gitlab::Graphql::FilterableArray, Gitlab::Graphql::Connections::FilterableArrayConnection ) + GraphQL::Relay::BaseConnection.register_connection_implementation( + Gitlab::Graphql::ExternallyPaginatedArray, + Gitlab::Graphql::Connections::ExternallyPaginatedArrayConnection + ) end end end diff --git a/lib/gitlab/graphql/connections/externally_paginated_array_connection.rb b/lib/gitlab/graphql/connections/externally_paginated_array_connection.rb new file mode 100644 index 0000000000000000000000000000000000000000..f0861260691036b953ae46afc26bbd76753c7a1e --- /dev/null +++ b/lib/gitlab/graphql/connections/externally_paginated_array_connection.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +# Make a customized connection type +module Gitlab + module Graphql + module Connections + class ExternallyPaginatedArrayConnection < GraphQL::Relay::ArrayConnection + # As the pagination happens externally + # we just return all the nodes here. + def sliced_nodes + @nodes + end + + def start_cursor + nodes.previous_cursor + end + + def end_cursor + nodes.next_cursor + end + + def next_page? + end_cursor.present? + end + + def previous_page? + start_cursor.present? + end + + alias_method :has_next_page, :next_page? + alias_method :has_previous_page, :previous_page? + end + end + end +end diff --git a/lib/gitlab/graphql/docs/renderer.rb b/lib/gitlab/graphql/docs/renderer.rb index 41aef64f683c7cd5fd387a3b64331247842dc47a..6abd56c89c6a3e6694a772b95c3b47a09c1b4f4f 100644 --- a/lib/gitlab/graphql/docs/renderer.rb +++ b/lib/gitlab/graphql/docs/renderer.rb @@ -10,7 +10,7 @@ module Gitlab # It uses graphql-docs helpers and schema parser, more information in https://github.com/gjtorikian/graphql-docs. # # Arguments: - # schema - the GraphQL schema defition. For GitLab should be: GitlabSchema.graphql_definition + # schema - the GraphQL schema definition. For GitLab should be: GitlabSchema.graphql_definition # output_dir: The folder where the markdown files will be saved # template: The path of the haml template to be parsed class Renderer diff --git a/lib/gitlab/graphql/docs/templates/default.md.haml b/lib/gitlab/graphql/docs/templates/default.md.haml index 33acff38ef4c3bb1c54e61101424a0437b82f07e..52568286dca1861234fa46e98901e8c29f835db7 100644 --- a/lib/gitlab/graphql/docs/templates/default.md.haml +++ b/lib/gitlab/graphql/docs/templates/default.md.haml @@ -9,11 +9,15 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graphiql). - ## Objects +Each table below documents a GraphQL type. Types match loosely to models, but not all +fields and methods on a model are available via GraphQL. \ - objects.each do |type| - unless type[:fields].empty? - = "### #{type[:name]}" + = "## #{type[:name]}" + - if type[:description]&.present? + \ + = type[:description] \ ~ "| Name | Type | Description |" ~ "| --- | ---- | ---------- |" diff --git a/lib/gitlab/graphql/externally_paginated_array.rb b/lib/gitlab/graphql/externally_paginated_array.rb new file mode 100644 index 0000000000000000000000000000000000000000..4797fe15cd3b823c1d12665096f13a766c9a1084 --- /dev/null +++ b/lib/gitlab/graphql/externally_paginated_array.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Gitlab + module Graphql + class ExternallyPaginatedArray < Array + attr_reader :previous_cursor, :next_cursor + + def initialize(previous_cursor, next_cursor, *args) + super(args) + @previous_cursor = previous_cursor + @next_cursor = next_cursor + end + end + end +end diff --git a/lib/gitlab/group_search_results.rb b/lib/gitlab/group_search_results.rb index 334642f252e55689ab1478c1bb6413dfdff6f593..8597903ad00787b3e1ab58a3785603f42d8de2a8 100644 --- a/lib/gitlab/group_search_results.rb +++ b/lib/gitlab/group_search_results.rb @@ -30,7 +30,7 @@ module Gitlab # rubocop:enable CodeReuse/ActiveRecord def issuable_params - super.merge(group_id: group.id) + super.merge(group_id: group.id, include_subgroups: true) end end end diff --git a/lib/gitlab/health_checks/puma_check.rb b/lib/gitlab/health_checks/puma_check.rb index 7aafe29fbae742e51edc1dcf3e16d606429dcbc8..9f09070a57d436cc7761d015d3657d01ecf50d43 100644 --- a/lib/gitlab/health_checks/puma_check.rb +++ b/lib/gitlab/health_checks/puma_check.rb @@ -18,7 +18,7 @@ module Gitlab end def check - return unless defined?(::Puma) + return unless Gitlab::Runtime.puma? stats = Puma.stats stats = JSON.parse(stats) diff --git a/lib/gitlab/health_checks/unicorn_check.rb b/lib/gitlab/health_checks/unicorn_check.rb index a30ae0152574edf90019c3ae0201bfdc000650fa..cdc6d2a751916ef72637dec17e3688e7a04a9456 100644 --- a/lib/gitlab/health_checks/unicorn_check.rb +++ b/lib/gitlab/health_checks/unicorn_check.rb @@ -30,7 +30,7 @@ module Gitlab # to change so we can cache the list of servers. def http_servers strong_memoize(:http_servers) do - next unless defined?(::Unicorn::HttpServer) + next unless Gitlab::Runtime.unicorn? ObjectSpace.each_object(::Unicorn::HttpServer).to_a end diff --git a/lib/gitlab/highlight.rb b/lib/gitlab/highlight.rb index 2c243a0d0ae6e24d1d8512d8c661ca90dc3ce5c0..22b9a038768fc6f2db2afa0eb24091448ecceff1 100644 --- a/lib/gitlab/highlight.rb +++ b/lib/gitlab/highlight.rb @@ -68,7 +68,7 @@ module Gitlab end def timeout_time - Sidekiq.server? ? TIMEOUT_BACKGROUND : TIMEOUT_FOREGROUND + Gitlab::Runtime.sidekiq? ? TIMEOUT_BACKGROUND : TIMEOUT_FOREGROUND end def link_dependencies(text, highlighted_text) diff --git a/lib/gitlab/import/merge_request_helpers.rb b/lib/gitlab/import/merge_request_helpers.rb index 4bc39868389c0bb3a2645e3ce238247af870d651..c5694d95aa1d679b9ad5130875ab72d37f360ae7 100644 --- a/lib/gitlab/import/merge_request_helpers.rb +++ b/lib/gitlab/import/merge_request_helpers.rb @@ -60,6 +60,7 @@ module Gitlab diff.importing = true diff.save diff.save_git_content + diff.set_as_latest_diff end end end diff --git a/lib/gitlab/import_export.rb b/lib/gitlab/import_export.rb index 516e7f54a6ec7678122b202f15deb702d4023f5b..8ce6549c0c747e718f2ab3b1df2286a6cc5c0fbd 100644 --- a/lib/gitlab/import_export.rb +++ b/lib/gitlab/import_export.rb @@ -50,6 +50,14 @@ module Gitlab 'VERSION' end + def gitlab_version_filename + 'GITLAB_VERSION' + end + + def gitlab_revision_filename + 'GITLAB_REVISION' + end + def export_filename(exportable:) basename = "#{Time.now.strftime('%Y-%m-%d_%H-%M-%3N')}_#{exportable.full_path.tr('/', '_')}" diff --git a/lib/gitlab/import_export/attribute_cleaner.rb b/lib/gitlab/import_export/attribute_cleaner.rb index 00c4c41e6be2aa538915ca4050aca85a15764eeb..d1c20dff799d2f295fd02184056d8416c4107b6b 100644 --- a/lib/gitlab/import_export/attribute_cleaner.rb +++ b/lib/gitlab/import_export/attribute_cleaner.rb @@ -3,7 +3,14 @@ module Gitlab module ImportExport class AttributeCleaner - ALLOWED_REFERENCES = RelationFactory::PROJECT_REFERENCES + RelationFactory::USER_REFERENCES + %w[group_id commit_id discussion_id custom_attributes] + ALLOWED_REFERENCES = [ + *ProjectRelationFactory::PROJECT_REFERENCES, + *ProjectRelationFactory::USER_REFERENCES, + 'group_id', + 'commit_id', + 'discussion_id', + 'custom_attributes' + ].freeze PROHIBITED_REFERENCES = Regexp.union(/\Acached_markdown_version\Z/, /_id\Z/, /_ids\Z/, /_html\Z/, /attributes/).freeze def self.clean(*args) diff --git a/lib/gitlab/import_export/base_object_builder.rb b/lib/gitlab/import_export/base_object_builder.rb new file mode 100644 index 0000000000000000000000000000000000000000..ec66b7a7a4f52e3ffbe885313c8aa6a374eef2f5 --- /dev/null +++ b/lib/gitlab/import_export/base_object_builder.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +module Gitlab + module ImportExport + # Base class for Group & Project Object Builders. + # This class is not intended to be used on its own but + # rather inherited from. + # + # Cache keeps 1000 entries at most, 1000 is chosen based on: + # - one cache entry uses around 0.5K memory, 1000 items uses around 500K. + # (leave some buffer it should be less than 1M). It is afforable cost for project import. + # - for projects in Gitlab.com, it seems 1000 entries for labels/milestones is enough. + # For example, gitlab has ~970 labels and 26 milestones. + LRU_CACHE_SIZE = 1000 + + class BaseObjectBuilder + def self.build(*args) + new(*args).find + end + + def initialize(klass, attributes) + @klass = klass.ancestors.include?(Label) ? Label : klass + @attributes = attributes + + if Gitlab::SafeRequestStore.active? + @lru_cache = cache_from_request_store + @cache_key = [klass, attributes] + end + end + + def find + find_with_cache do + find_object || klass.create(prepare_attributes) + end + end + + protected + + def where_clauses + raise NotImplementedError + end + + # attributes wrapped in a method to be + # adjusted in sub-class if needed + def prepare_attributes + attributes + end + + private + + attr_reader :klass, :attributes, :lru_cache, :cache_key + + def find_with_cache + return yield unless lru_cache && cache_key + + lru_cache[cache_key] ||= yield + end + + def cache_from_request_store + Gitlab::SafeRequestStore[:lru_cache] ||= LruRedux::Cache.new(LRU_CACHE_SIZE) + end + + def find_object + klass.where(where_clause).first + end + + def where_clause + where_clauses.reduce(:and) + end + + def table + @table ||= klass.arel_table + end + + # Returns Arel clause: + # `"{table_name}"."{attrs.keys[0]}" = '{attrs.values[0]} AND {table_name}"."{attrs.keys[1]}" = '{attrs.values[1]}"` + # from the given Hash of attributes. + def attrs_to_arel(attrs) + attrs.map do |key, value| + table[key].eq(value) + end.reduce(:and) + end + + # Returns Arel clause `"{table_name}"."title" = '{attributes['title']}'` + # if attributes has 'title key, otherwise `nil`. + def where_clause_for_title + attrs_to_arel(attributes.slice('title')) + end + + # Returns Arel clause `"{table_name}"."description" = '{attributes['description']}'` + # if attributes has 'description key, otherwise `nil`. + def where_clause_for_description + attrs_to_arel(attributes.slice('description')) + end + + # Returns Arel clause `"{table_name}"."created_at" = '{attributes['created_at']}'` + # if attributes has 'created_at key, otherwise `nil`. + def where_clause_for_created_at + attrs_to_arel(attributes.slice('created_at')) + end + end + end +end diff --git a/lib/gitlab/import_export/relation_factory.rb b/lib/gitlab/import_export/base_relation_factory.rb similarity index 50% rename from lib/gitlab/import_export/relation_factory.rb rename to lib/gitlab/import_export/base_relation_factory.rb index 1438a7db0017808e41d69e12ffff1b038ba41829..562b549f6a1f9a3fee4950ca1201784574454493 100644 --- a/lib/gitlab/import_export/relation_factory.rb +++ b/lib/gitlab/import_export/base_relation_factory.rb @@ -2,48 +2,32 @@ module Gitlab module ImportExport - class RelationFactory - prepend_if_ee('::EE::Gitlab::ImportExport::RelationFactory') # rubocop: disable Cop/InjectEnterpriseEditionModule - - 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', - create_access_levels: 'ProtectedTag::CreateAccessLevel', - labels: :project_labels, - priorities: :label_priorities, - auto_devops: :project_auto_devops, - label: :project_label, - custom_attributes: 'ProjectCustomAttribute', - project_badges: 'Badge', - metrics: 'MergeRequest::Metrics', - ci_cd_settings: 'ProjectCiCdSetting', - error_tracking_setting: 'ErrorTracking::ProjectErrorTrackingSetting', - links: 'Releases::Link', - metrics_setting: 'ProjectMetricsSetting' }.freeze - - USER_REFERENCES = %w[author_id assignee_id updated_by_id merged_by_id latest_closed_by_id user_id created_by_id last_edited_by_id merge_user_id resolved_by_id closed_by_id owner_id].freeze - - PROJECT_REFERENCES = %w[project_id source_project_id target_project_id].freeze - - BUILD_MODELS = %i[Ci::Build commit_status].freeze + class BaseRelationFactory + include Gitlab::Utils::StrongMemoize IMPORTED_OBJECT_MAX_RETRIES = 5.freeze - EXISTING_OBJECT_CHECK = %i[milestone milestones label labels project_label project_labels group_label group_labels project_feature merge_request ProjectCiCdSetting container_expiration_policy].freeze + OVERRIDES = {}.freeze + EXISTING_OBJECT_RELATIONS = %i[].freeze - TOKEN_RESET_MODELS = %i[Project Namespace Ci::Trigger Ci::Build Ci::Runner ProjectHook].freeze + # This represents all relations that have unique key on `project_id` or `group_id` + UNIQUE_RELATIONS = %i[].freeze - # This represents all relations that have unique key on `project_id` - UNIQUE_RELATIONS = %i[project_feature ProjectCiCdSetting container_expiration_policy].freeze + USER_REFERENCES = %w[ + author_id + assignee_id + updated_by_id + merged_by_id + latest_closed_by_id + user_id + created_by_id + last_edited_by_id + merge_user_id + resolved_by_id + closed_by_id owner_id + ].freeze + + TOKEN_RESET_MODELS = %i[Project Namespace Group Ci::Trigger Ci::Build Ci::Runner ProjectHook].freeze def self.create(*args) new(*args).create @@ -58,16 +42,16 @@ module Gitlab relation_name.to_s.constantize end - def initialize(relation_sym:, relation_hash:, members_mapper:, merge_requests_mapping:, user:, project:, excluded_keys: []) + def initialize(relation_sym:, relation_hash:, members_mapper:, object_builder:, merge_requests_mapping: nil, user:, importable:, excluded_keys: []) @relation_name = self.class.overrides[relation_sym]&.to_sym || relation_sym @relation_hash = relation_hash.except('noteable_id') @members_mapper = members_mapper + @object_builder = object_builder @merge_requests_mapping = merge_requests_mapping @user = user - @project = project + @importable = importable @imported_object_retries = 0 - - @relation_hash['project_id'] = @project.id + @relation_hash[importable_column_name] = @importable.id # Remove excluded keys from relation_hash # We don't do this in the parsed_relation_hash because of the 'transformed attributes' @@ -82,48 +66,46 @@ module Gitlab # the relation_hash, updating references with new object IDs, mapping users using # the "members_mapper" object, also updating notes if required. def create - return if unknown_service? - - # Do not import legacy triggers - return if !Feature.enabled?(:use_legacy_pipeline_triggers, @project) && legacy_trigger? + return if invalid_relation? + setup_base_models setup_models generate_imported_object end def self.overrides - OVERRIDES + self::OVERRIDES end - def self.existing_object_check - EXISTING_OBJECT_CHECK + def self.existing_object_relations + self::EXISTING_OBJECT_RELATIONS end private + def invalid_relation? + false + end + def setup_models - case @relation_name - when :merge_request_diff_files then setup_diff - when :notes then setup_note - end + raise NotImplementedError + end + + def unique_relations + # define in sub-class if any + self.class::UNIQUE_RELATIONS + end + def setup_base_models update_user_references - update_project_references - update_group_references remove_duplicate_assignees - - if @relation_name == :'Ci::Pipeline' - update_merge_request_references - setup_pipeline - end - reset_tokens! remove_encrypted_attributes! end def update_user_references - USER_REFERENCES.each do |reference| + self.class::USER_REFERENCES.each do |reference| if @relation_hash[reference] @relation_hash[reference] = @members_mapper.map[@relation_hash[reference]] end @@ -138,95 +120,14 @@ module Gitlab @relation_hash['issue_assignees'].uniq!(&:user_id) end - def setup_note - set_note_author - # attachment is deprecated and note uploads are handled by Markdown uploader - @relation_hash['attachment'] = nil - end - - # Sets the author for a note. If the user importing the project - # has admin access, an actual mapping with new project members - # will be used. Otherwise, a note stating the original author name - # is left. - def set_note_author - old_author_id = @relation_hash['author_id'] - author = @relation_hash.delete('author') - - update_note_for_missing_author(author['name']) unless has_author?(old_author_id) - end - - def has_author?(old_author_id) - admin_user? && @members_mapper.include?(old_author_id) - end - - def missing_author_note(updated_at, author_name) - timestamp = updated_at.split('.').first - "\n\n *By #{author_name} on #{timestamp} (imported from GitLab project)*" - end - def generate_imported_object - if BUILD_MODELS.include?(@relation_name) - @relation_hash.delete('trace') # old export files have trace - @relation_hash.delete('token') - @relation_hash.delete('commands') - @relation_hash.delete('artifacts_file_store') - @relation_hash.delete('artifacts_metadata_store') - @relation_hash.delete('artifacts_size') - - imported_object - elsif @relation_name == :merge_requests - MergeRequestParser.new(@project, @relation_hash.delete('diff_head_sha'), imported_object, @relation_hash).parse! - else - imported_object - end - end - - def update_project_references - # If source and target are the same, populate them with the new project ID. - if @relation_hash['source_project_id'] - @relation_hash['source_project_id'] = same_source_and_target? ? @relation_hash['project_id'] : MergeRequestParser::FORKED_PROJECT_ID - end - - @relation_hash['target_project_id'] = @relation_hash['project_id'] if @relation_hash['target_project_id'] - end - - def same_source_and_target? - @relation_hash['target_project_id'] && @relation_hash['target_project_id'] == @relation_hash['source_project_id'] - end - - def update_group_references - return unless self.class.existing_object_check.include?(@relation_name) - return unless @relation_hash['group_id'] - - @relation_hash['group_id'] = @project.namespace_id - end - - # This code is a workaround for broken project exports that don't - # export merge requests with CI pipelines (i.e. exports that were - # generated from - # https://gitlab.com/gitlab-org/gitlab/merge_requests/17844). - # This method can be removed in GitLab 12.6. - def update_merge_request_references - # If a merge request was properly created, we don't need to fix - # up this export. - return if @relation_hash['merge_request'] - - merge_request_id = @relation_hash['merge_request_id'] - - return unless merge_request_id - - new_merge_request_id = @merge_requests_mapping[merge_request_id] - - return unless new_merge_request_id - - @relation_hash['merge_request_id'] = new_merge_request_id - parsed_relation_hash['merge_request_id'] = new_merge_request_id + imported_object end def reset_tokens! - return unless Gitlab::ImportExport.reset_tokens? && TOKEN_RESET_MODELS.include?(@relation_name) + return unless Gitlab::ImportExport.reset_tokens? && self.class::TOKEN_RESET_MODELS.include?(@relation_name) - # If we import/export a project to the same instance, tokens will have to be reset. + # If we import/export to the same instance, tokens will have to be reset. # We also have to reset them to avoid issues when the gitlab secrets file cannot be copied across. relation_class.attribute_names.select { |name| name.include?('token') }.each do |token| @relation_hash[token] = nil @@ -245,6 +146,14 @@ module Gitlab @relation_class ||= self.class.relation_class(@relation_name) end + def importable_column_name + importable_class_name.concat('_id') + end + + def importable_class_name + @importable.class.to_s.downcase + end + def imported_object if existing_or_new_object.respond_to?(:importing) existing_or_new_object.importing = true @@ -258,37 +167,16 @@ module Gitlab retry if @imported_object_retries < IMPORTED_OBJECT_MAX_RETRIES end - def update_note_for_missing_author(author_name) - @relation_hash['note'] = '*Blank note*' if @relation_hash['note'].blank? - @relation_hash['note'] = "#{@relation_hash['note']}#{missing_author_note(@relation_hash['updated_at'], author_name)}" - end - - def admin_user? - @user.admin? - end - def parsed_relation_hash @parsed_relation_hash ||= Gitlab::ImportExport::AttributeCleaner.clean(relation_hash: @relation_hash, relation_class: relation_class) end - def setup_diff - @relation_hash['diff'] = @relation_hash.delete('utf8_diff') - end - - def setup_pipeline - @relation_hash.fetch('stages', []).each do |stage| - stage.statuses.each do |status| - status.pipeline = imported_object - end - end - end - def existing_or_new_object # Only find existing records to avoid mapping tables such as milestones # Otherwise always create the record, skipping the extra SELECT clause. @existing_or_new_object ||= begin - if self.class.existing_object_check.include?(@relation_name) + if existing_object? attribute_hash = attribute_hash_for(['events']) existing_object.assign_attributes(attribute_hash) if attribute_hash.any? @@ -307,7 +195,7 @@ module Gitlab end def attribute_hash_for(attributes) - attributes.inject({}) do |hash, value| + attributes.each_with_object({}) do |hash, value| hash[value] = parsed_relation_hash.delete(value) if parsed_relation_hash[value] hash end @@ -317,31 +205,101 @@ module Gitlab @existing_object ||= find_or_create_object! end - def unknown_service? - @relation_name == :services && parsed_relation_hash['type'] && - !Object.const_defined?(parsed_relation_hash['type']) + def unique_relation_object + unique_relation_object = relation_class.find_or_create_by(importable_column_name => @importable.id) + unique_relation_object.assign_attributes(parsed_relation_hash) + unique_relation_object + end + + def find_or_create_object! + return unique_relation_object if unique_relation? + + # Can't use IDs as validation exists calling `group` or `project` attributes + finder_hash = parsed_relation_hash.tap do |hash| + if relation_class.attribute_method?('group_id') && @importable.is_a?(Project) + hash['group'] = @importable.group + end + + hash[importable_class_name] = @importable if relation_class.reflect_on_association(importable_class_name.to_sym) + hash.delete(importable_column_name) + end + + @object_builder.build(relation_class, finder_hash) end - def legacy_trigger? - @relation_name == :'Ci::Trigger' && @relation_hash['owner_id'].nil? + def setup_note + set_note_author + # attachment is deprecated and note uploads are handled by Markdown uploader + @relation_hash['attachment'] = nil end - def find_or_create_object! - if UNIQUE_RELATIONS.include?(@relation_name) - unique_relation_object = relation_class.find_or_create_by(project_id: @project.id) - unique_relation_object.assign_attributes(parsed_relation_hash) + # Sets the author for a note. If the user importing the project + # has admin access, an actual mapping with new project members + # will be used. Otherwise, a note stating the original author name + # is left. + def set_note_author + old_author_id = @relation_hash['author_id'] + author = @relation_hash.delete('author') + + update_note_for_missing_author(author['name']) unless has_author?(old_author_id) + end + + def has_author?(old_author_id) + admin_user? && @members_mapper.include?(old_author_id) + end + + def missing_author_note(updated_at, author_name) + timestamp = updated_at.split('.').first + "\n\n *By #{author_name} on #{timestamp} (imported from GitLab project)*" + end + + def update_note_for_missing_author(author_name) + @relation_hash['note'] = '*Blank note*' if @relation_hash['note'].blank? + @relation_hash['note'] = "#{@relation_hash['note']}#{missing_author_note(@relation_hash['updated_at'], author_name)}" + end - return unique_relation_object + def admin_user? + @user.admin? + end + + def existing_object? + strong_memoize(:_existing_object) do + self.class.existing_object_relations.include?(@relation_name) || unique_relation? end + end - # Can't use IDs as validation exists calling `group` or `project` attributes - finder_hash = parsed_relation_hash.tap do |hash| - hash['group'] = @project.group if relation_class.attribute_method?('group_id') - hash['project'] = @project if relation_class.reflect_on_association(:project) - hash.delete('project_id') + def unique_relation? + strong_memoize(:unique_relation) do + importable_foreign_key.present? && + (has_unique_index_on_importable_fk? || uses_importable_fk_as_primary_key?) end + end + + def has_unique_index_on_importable_fk? + cache = cached_has_unique_index_on_importable_fk + table_name = relation_class.table_name + return cache[table_name] if cache.has_key?(table_name) + + index_exists = + ActiveRecord::Base.connection.index_exists?( + relation_class.table_name, + importable_foreign_key, + unique: true) + + cache[table_name] = index_exists + end + + # Avoid unnecessary DB requests + def cached_has_unique_index_on_importable_fk + Thread.current[:cached_has_unique_index_on_importable_fk] ||= {} + end + + def uses_importable_fk_as_primary_key? + relation_class.primary_key == importable_foreign_key + end - GroupProjectObjectBuilder.build(relation_class, finder_hash) + def importable_foreign_key + relation_class.reflect_on_association(importable_class_name.to_sym)&.foreign_key end end end diff --git a/lib/gitlab/import_export/group_project_object_builder.rb b/lib/gitlab/import_export/group_project_object_builder.rb index b94839363df886527e8102dfa0f0f03f9f02e424..d6d780f165e63fa478630f131a519ab3e94e7949 100644 --- a/lib/gitlab/import_export/group_project_object_builder.rb +++ b/lib/gitlab/import_export/group_project_object_builder.rb @@ -11,35 +11,29 @@ module Gitlab # finds or initializes a label with the given attributes. # # It also adds some logic around Group Labels/Milestones for edge cases. - class GroupProjectObjectBuilder + class GroupProjectObjectBuilder < BaseObjectBuilder def self.build(*args) Project.transaction do - new(*args).find + super end end def initialize(klass, attributes) - @klass = klass < Label ? Label : klass - @attributes = attributes + super + @group = @attributes['group'] @project = @attributes['project'] end def find - find_object || klass.create(project_attributes) + return if epic? && group.nil? + + super end private - attr_reader :klass, :attributes, :group, :project - - def find_object - klass.where(where_clause).first - end - - def where_clause - where_clauses.reduce(:and) - end + attr_reader :group, :project def where_clauses [ @@ -54,32 +48,18 @@ module Gitlab # or, if group is present: # `"{table_name}"."project_id" = {project.id} OR "{table_name}"."group_id" = {group.id}` def where_clause_base - clause = table[:project_id].eq(project.id) if project - clause = clause.or(table[:group_id].eq(group.id)) if group - - clause - end - - # Returns Arel clause `"{table_name}"."title" = '{attributes['title']}'` - # if attributes has 'title key, otherwise `nil`. - def where_clause_for_title - attrs_to_arel(attributes.slice('title')) - end - - # Returns Arel clause: - # `"{table_name}"."{attrs.keys[0]}" = '{attrs.values[0]} AND {table_name}"."{attrs.keys[1]}" = '{attrs.values[1]}"` - # from the given Hash of attributes. - def attrs_to_arel(attrs) - attrs.map do |key, value| - table[key].eq(value) - end.reduce(:and) + [].tap do |clauses| + clauses << table[:project_id].eq(project.id) if project + clauses << table[:group_id].eq(group.id) if group + end.reduce(:or) end - def table - @table ||= klass.arel_table + # Returns Arel clause for a particular model or `nil`. + def where_clause_for_klass + attrs_to_arel(attributes.slice('iid')) if merge_request? end - def project_attributes + def prepare_attributes attributes.except('group').tap do |atts| if label? atts['type'] = 'ProjectLabel' # Always create project labels @@ -108,6 +88,10 @@ module Gitlab klass == MergeRequest end + def epic? + klass == Epic + end + # If an existing group milestone used the IID # claim the IID back and set the group milestone to use one available # This is necessary to fix situations like the following: @@ -124,13 +108,6 @@ module Gitlab milestone.ensure_project_iid! milestone.save! end - - protected - - # Returns Arel clause for a particular model or `nil`. - def where_clause_for_klass - return attrs_to_arel(attributes.slice('iid')) if merge_request? - end end end end diff --git a/lib/gitlab/import_export/group_tree_saver.rb b/lib/gitlab/import_export/group_tree_saver.rb index 8d2fb881cc0e21055b1cf9a56e04ca069554d735..2effcd01e3004164b8aaa080b1519f95d5a0a5a2 100644 --- a/lib/gitlab/import_export/group_tree_saver.rb +++ b/lib/gitlab/import_export/group_tree_saver.rb @@ -3,7 +3,7 @@ module Gitlab module ImportExport class GroupTreeSaver - attr_reader :full_path + attr_reader :full_path, :shared def initialize(group:, current_user:, shared:, params: {}) @params = params diff --git a/lib/gitlab/import_export/import_export.yml b/lib/gitlab/import_export/import_export.yml index 4f4b4c02eb916db93b62224be5f6548f8bb3b646..2acb79e3e227a1a79572526f09ea69f8c1a1d82a 100644 --- a/lib/gitlab/import_export/import_export.yml +++ b/lib/gitlab/import_export/import_export.yml @@ -16,6 +16,7 @@ tree: - :timelogs - notes: - :author + - :award_emoji - events: - :push_event_payload - label_links: @@ -24,24 +25,30 @@ tree: - milestone: - events: - :push_event_payload + - issue_milestones: + - :milestone - resource_label_events: - label: - :priorities - :issue_assignees - :zoom_meetings - :sentry_issue + - :award_emoji - snippets: - :award_emoji - notes: - :author + - :award_emoji - releases: - :links - project_members: - :user - merge_requests: - :metrics + - :award_emoji - notes: - :author + - :award_emoji - events: - :push_event_payload - :suggestions @@ -57,6 +64,8 @@ tree: - milestone: - events: - :push_event_payload + - merge_request_milestones: + - :milestone - resource_label_events: - label: - :priorities @@ -168,6 +177,8 @@ excluded_attributes: - :secret - :encrypted_secret_token - :encrypted_secret_token_iv + - :repository_storage + - :storage_version merge_request_diff: - :external_diff - :stored_externally @@ -202,6 +213,12 @@ excluded_attributes: - :latest_merge_request_diff_id - :head_pipeline_id - :state_id + issue_milestones: + - :milestone_id + - :issue_id + merge_request_milestones: + - :milestone_id + - :merge_request_id award_emoji: - :awardable_id statuses: @@ -223,6 +240,7 @@ excluded_attributes: - :upstream_pipeline_id - :resource_group_id - :waiting_for_resource_at + - :processed sentry_issue: - :issue_id push_event_payload: @@ -305,6 +323,13 @@ excluded_attributes: - :board_id - :label_id - :milestone_id + epic: + - :start_date_sourcing_milestone_id + - :due_date_sourcing_milestone_id + - :parent_id + - :state_id + - :start_date_sourcing_epic_id + - :due_date_sourcing_epic_id methods: notes: - :type @@ -357,6 +382,7 @@ ee: - design_versions: - actions: - :design # Duplicate export of issues.designs in order to link the record to both Issue and Action + - :epic - protected_branches: - :unprotect_access_levels - protected_environments: diff --git a/lib/gitlab/import_export/import_failure_service.rb b/lib/gitlab/import_export/import_failure_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..eeaf10870c86f7517bac3ba452e1a9fa0fb7df2d --- /dev/null +++ b/lib/gitlab/import_export/import_failure_service.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +module Gitlab + module ImportExport + class ImportFailureService + RETRIABLE_EXCEPTIONS = [GRPC::DeadlineExceeded, ActiveRecord::QueryCanceled].freeze + + attr_reader :importable + + def initialize(importable) + @importable = importable + @association = importable.association(:import_failures) + end + + def with_retry(relation_key, relation_index) + on_retry = -> (exception, retry_count, *_args) do + log_import_failure(relation_key, relation_index, exception, retry_count) + end + + Retriable.with_context(:relation_import, on_retry: on_retry) do + yield + end + end + + def log_import_failure(relation_key, relation_index, exception, retry_count = 0) + extra = { + relation_key: relation_key, + relation_index: relation_index, + retry_count: retry_count + } + extra[importable_column_name] = importable.id + + Gitlab::ErrorTracking.track_exception(exception, extra) + + attributes = { + exception_class: exception.class.to_s, + exception_message: exception.message.truncate(255), + correlation_id_value: Labkit::Correlation::CorrelationId.current_or_new_id + }.merge(extra) + + ImportFailure.create(attributes) + end + + private + + def importable_column_name + @importable_column_name ||= @association.reflection.foreign_key.to_sym + end + end + end +end diff --git a/lib/gitlab/import_export/project_relation_factory.rb b/lib/gitlab/import_export/project_relation_factory.rb new file mode 100644 index 0000000000000000000000000000000000000000..e27bb9d3af1d9ffb2b00d015ae314e2db84e441a --- /dev/null +++ b/lib/gitlab/import_export/project_relation_factory.rb @@ -0,0 +1,184 @@ +# frozen_string_literal: true + +module Gitlab + module ImportExport + class ProjectRelationFactory < BaseRelationFactory + prepend_if_ee('::EE::Gitlab::ImportExport::ProjectRelationFactory') # rubocop: disable Cop/InjectEnterpriseEditionModule + + 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', + create_access_levels: 'ProtectedTag::CreateAccessLevel', + labels: :project_labels, + priorities: :label_priorities, + auto_devops: :project_auto_devops, + label: :project_label, + custom_attributes: 'ProjectCustomAttribute', + project_badges: 'Badge', + metrics: 'MergeRequest::Metrics', + ci_cd_settings: 'ProjectCiCdSetting', + error_tracking_setting: 'ErrorTracking::ProjectErrorTrackingSetting', + links: 'Releases::Link', + metrics_setting: 'ProjectMetricsSetting' }.freeze + + BUILD_MODELS = %i[Ci::Build commit_status].freeze + + GROUP_REFERENCES = %w[group_id].freeze + + PROJECT_REFERENCES = %w[project_id source_project_id target_project_id].freeze + + EXISTING_OBJECT_RELATIONS = %i[ + milestone + milestones + label + labels + project_label + project_labels + group_label + group_labels + project_feature + merge_request + epic + ProjectCiCdSetting + container_expiration_policy + ].freeze + + def create + @object = super + + # We preload the project, user, and group to re-use objects + @object = preload_keys(@object, PROJECT_REFERENCES, @importable) + @object = preload_keys(@object, GROUP_REFERENCES, @importable.group) + @object = preload_keys(@object, USER_REFERENCES, @user) + end + + private + + def invalid_relation? + # Do not create relation if it is: + # - An unknown service + # - A legacy trigger + unknown_service? || + (!Feature.enabled?(:use_legacy_pipeline_triggers, @importable) && legacy_trigger?) + end + + def setup_models + case @relation_name + when :merge_request_diff_files then setup_diff + when :notes then setup_note + when :'Ci::Pipeline' then setup_pipeline + when *BUILD_MODELS then setup_build + end + + update_project_references + update_group_references + end + + def generate_imported_object + if @relation_name == :merge_requests + MergeRequestParser.new(@importable, @relation_hash.delete('diff_head_sha'), super, @relation_hash).parse! + else + super + end + end + + def update_project_references + # If source and target are the same, populate them with the new project ID. + if @relation_hash['source_project_id'] + @relation_hash['source_project_id'] = same_source_and_target? ? @relation_hash['project_id'] : MergeRequestParser::FORKED_PROJECT_ID + end + + @relation_hash['target_project_id'] = @relation_hash['project_id'] if @relation_hash['target_project_id'] + end + + def same_source_and_target? + @relation_hash['target_project_id'] && @relation_hash['target_project_id'] == @relation_hash['source_project_id'] + end + + def update_group_references + return unless existing_object? + return unless @relation_hash['group_id'] + + @relation_hash['group_id'] = @importable.namespace_id + end + + # This code is a workaround for broken project exports that don't + # export merge requests with CI pipelines (i.e. exports that were + # generated from + # https://gitlab.com/gitlab-org/gitlab/merge_requests/17844). + # This method can be removed in GitLab 12.6. + def update_merge_request_references + # If a merge request was properly created, we don't need to fix + # up this export. + return if @relation_hash['merge_request'] + + merge_request_id = @relation_hash['merge_request_id'] + + return unless merge_request_id + + new_merge_request_id = @merge_requests_mapping[merge_request_id] + + return unless new_merge_request_id + + @relation_hash['merge_request_id'] = new_merge_request_id + parsed_relation_hash['merge_request_id'] = new_merge_request_id + end + + def setup_build + @relation_hash.delete('trace') # old export files have trace + @relation_hash.delete('token') + @relation_hash.delete('commands') + @relation_hash.delete('artifacts_file_store') + @relation_hash.delete('artifacts_metadata_store') + @relation_hash.delete('artifacts_size') + end + + def setup_diff + @relation_hash['diff'] = @relation_hash.delete('utf8_diff') + end + + def setup_pipeline + update_merge_request_references + + @relation_hash.fetch('stages', []).each do |stage| + stage.statuses.each do |status| + status.pipeline = imported_object + end + end + end + + def unknown_service? + @relation_name == :services && parsed_relation_hash['type'] && + !Object.const_defined?(parsed_relation_hash['type']) + end + + def legacy_trigger? + @relation_name == :'Ci::Trigger' && @relation_hash['owner_id'].nil? + end + + def preload_keys(object, references, value) + return object unless value + + references.each do |key| + attribute = "#{key.delete_suffix('_id')}=".to_sym + next unless object.respond_to?(key) && object.respond_to?(attribute) + + if object.read_attribute(key) == value&.id + object.public_send(attribute, value) # rubocop:disable GitlabSecurity/PublicSend + end + end + + object + end + end + end +end diff --git a/lib/gitlab/import_export/project_tree_restorer.rb b/lib/gitlab/import_export/project_tree_restorer.rb index e274b68a94f592072ba6786e217a4a137b62cbdc..e598cfc143e7c598fb6c7ecef0709196bbca6e13 100644 --- a/lib/gitlab/import_export/project_tree_restorer.rb +++ b/lib/gitlab/import_export/project_tree_restorer.rb @@ -10,7 +10,7 @@ module Gitlab def initialize(user:, shared:, project:) @path = File.join(shared.export_path, 'project.json') @user = user - @shared = shared + @shared = shared @project = project end @@ -48,6 +48,7 @@ module Gitlab shared: @shared, importable: @project, tree_hash: @tree_hash, + object_builder: object_builder, members_mapper: members_mapper, relation_factory: relation_factory, reader: reader @@ -60,8 +61,12 @@ module Gitlab importable: @project) end + def object_builder + Gitlab::ImportExport::GroupProjectObjectBuilder + end + def relation_factory - Gitlab::ImportExport::RelationFactory + Gitlab::ImportExport::ProjectRelationFactory end def reader diff --git a/lib/gitlab/import_export/relation_tree_restorer.rb b/lib/gitlab/import_export/relation_tree_restorer.rb index d9c253788b4ea5076a0de29eafb0e8f1e7c72921..44cf90fb86a333292907d656a75087dd8b4343d6 100644 --- a/lib/gitlab/import_export/relation_tree_restorer.rb +++ b/lib/gitlab/import_export/relation_tree_restorer.rb @@ -4,19 +4,20 @@ module Gitlab module ImportExport class RelationTreeRestorer # Relations which cannot be saved at project level (and have a group assigned) - GROUP_MODELS = [GroupLabel, Milestone].freeze + GROUP_MODELS = [GroupLabel, Milestone, Epic].freeze attr_reader :user attr_reader :shared attr_reader :importable attr_reader :tree_hash - def initialize(user:, shared:, importable:, tree_hash:, members_mapper:, relation_factory:, reader:) + def initialize(user:, shared:, importable:, tree_hash:, members_mapper:, object_builder:, relation_factory:, reader:) @user = user @shared = shared @importable = importable @tree_hash = tree_hash @members_mapper = members_mapper + @object_builder = object_builder @relation_factory = relation_factory @reader = reader end @@ -71,28 +72,18 @@ module Gitlab return if importable_class == Project && group_model?(relation_object) relation_object.assign_attributes(importable_class_sym => @importable) - relation_object.save! + + import_failure_service.with_retry(relation_key, relation_index) do + relation_object.save! + end save_id_mapping(relation_key, data_hash, relation_object) rescue => e - # re-raise if not enabled - raise e unless Feature.enabled?(:import_graceful_failures, @importable.group, default_enabled: true) - - log_import_failure(relation_key, relation_index, e) + import_failure_service.log_import_failure(relation_key, relation_index, e) end - def log_import_failure(relation_key, relation_index, exception) - Gitlab::ErrorTracking.track_exception(exception, - project_id: @importable.id, relation_key: relation_key, relation_index: relation_index) - - ImportFailure.create( - project: @importable, - relation_key: relation_key, - relation_index: relation_index, - exception_class: exception.class.to_s, - exception_message: exception.message.truncate(255), - correlation_id_value: Labkit::Correlation::CorrelationId.current_or_new_id - ) + def import_failure_service + @import_failure_service ||= ImportFailureService.new(@importable) end # Older, serialized CI pipeline exports may only have a @@ -224,15 +215,16 @@ module Gitlab def relation_factory_params(relation_key, data_hash) base_params = { - relation_sym: relation_key.to_sym, - relation_hash: data_hash, + relation_sym: relation_key.to_sym, + relation_hash: data_hash, + importable: @importable, members_mapper: @members_mapper, - user: @user, - excluded_keys: excluded_keys_for_relation(relation_key) + object_builder: @object_builder, + user: @user, + excluded_keys: excluded_keys_for_relation(relation_key) } base_params[:merge_requests_mapping] = merge_requests_mapping if importable_class == Project - base_params[importable_class_sym] = @importable base_params end end diff --git a/lib/gitlab/import_export/version_saver.rb b/lib/gitlab/import_export/version_saver.rb index 8230c0f1e774eae2d5d308825200f9a3a0c12e63..dab8bbf539d1a5bacd65adffd7f1f9e0ef35819e 100644 --- a/lib/gitlab/import_export/version_saver.rb +++ b/lib/gitlab/import_export/version_saver.rb @@ -13,6 +13,8 @@ module Gitlab mkdir_p(@shared.export_path) File.write(version_file, Gitlab::ImportExport.version, mode: 'w') + File.write(gitlab_version_file, Gitlab::VERSION, mode: 'w') + File.write(gitlab_revision_file, Gitlab.revision, mode: 'w') rescue => e @shared.error(e) false @@ -20,6 +22,14 @@ module Gitlab private + def gitlab_version_file + File.join(@shared.export_path, Gitlab::ImportExport.gitlab_version_filename) + end + + def gitlab_revision_file + File.join(@shared.export_path, Gitlab::ImportExport.gitlab_revision_filename) + end + def version_file File.join(@shared.export_path, Gitlab::ImportExport.version_filename) end diff --git a/lib/gitlab/kubernetes/helm.rb b/lib/gitlab/kubernetes/helm.rb index b5181670b937d26b77728b96f9dc16abd5ecbc45..c7c348ce9eb6f0f61ffedb228a570a29af790a4e 100644 --- a/lib/gitlab/kubernetes/helm.rb +++ b/lib/gitlab/kubernetes/helm.rb @@ -6,6 +6,7 @@ module Gitlab HELM_VERSION = '2.16.1' KUBECTL_VERSION = '1.13.12' NAMESPACE = 'gitlab-managed-apps' + NAMESPACE_LABELS = { 'app.gitlab.com/managed_by' => :gitlab }.freeze SERVICE_ACCOUNT = 'tiller' CLUSTER_ROLE_BINDING = 'tiller-admin' CLUSTER_ROLE = 'cluster-admin' diff --git a/lib/gitlab/kubernetes/helm/api.rb b/lib/gitlab/kubernetes/helm/api.rb index 978cafae9acc27061ed77b75aeebf524ce9caa8a..3ed07818302677c3effcc9789cfab9583f350192 100644 --- a/lib/gitlab/kubernetes/helm/api.rb +++ b/lib/gitlab/kubernetes/helm/api.rb @@ -6,7 +6,11 @@ module Gitlab class Api def initialize(kubeclient) @kubeclient = kubeclient - @namespace = Gitlab::Kubernetes::Namespace.new(Gitlab::Kubernetes::Helm::NAMESPACE, kubeclient) + @namespace = Gitlab::Kubernetes::Namespace.new( + Gitlab::Kubernetes::Helm::NAMESPACE, + kubeclient, + labels: Gitlab::Kubernetes::Helm::NAMESPACE_LABELS + ) end def install(command) diff --git a/lib/gitlab/kubernetes/kube_client.rb b/lib/gitlab/kubernetes/kube_client.rb index 66c28a9b70211a747e1daf394cd4f1b432327b5b..7cb7f46a623aa6752ec7456fd5891d1ac2bc040f 100644 --- a/lib/gitlab/kubernetes/kube_client.rb +++ b/lib/gitlab/kubernetes/kube_client.rb @@ -17,6 +17,7 @@ module Gitlab core: { group: 'api', version: 'v1' }, rbac: { group: 'apis/rbac.authorization.k8s.io', version: 'v1' }, extensions: { group: 'apis/extensions', version: 'v1beta1' }, + istio: { group: 'apis/networking.istio.io', version: 'v1alpha3' }, knative: { group: 'apis/serving.knative.dev', version: 'v1alpha1' } }.freeze @@ -83,6 +84,13 @@ module Gitlab :watch_pod_log, to: :core_client + # Gateway methods delegate to the apis/networking.istio.io api + # group client + delegate :create_gateway, + :get_gateway, + :update_gateway, + to: :istio_client + attr_reader :api_prefix, :kubeclient_options # We disable redirects through 'http_max_redirects: 0', diff --git a/lib/gitlab/kubernetes/namespace.rb b/lib/gitlab/kubernetes/namespace.rb index 8a3bea95a04241144a4783369187cf79ae6f87d1..9862861118be2d700164d486f27c02b168f03960 100644 --- a/lib/gitlab/kubernetes/namespace.rb +++ b/lib/gitlab/kubernetes/namespace.rb @@ -3,11 +3,12 @@ module Gitlab module Kubernetes class Namespace - attr_accessor :name + attr_accessor :name, :labels - def initialize(name, client) + def initialize(name, client, labels: nil) @name = name @client = client + @labels = labels end def exists? @@ -17,7 +18,7 @@ module Gitlab end def create! - resource = ::Kubeclient::Resource.new(metadata: { name: name }) + resource = ::Kubeclient::Resource.new(metadata: { name: name, labels: labels }) log_event(:begin_create) @client.create_namespace(resource) diff --git a/lib/gitlab/legacy_github_import/client.rb b/lib/gitlab/legacy_github_import/client.rb index b23efd64dee2913d4cd7c66d9b490b797bf5af13..34634d20a16c3057555667d619e984cf68ff1cea 100644 --- a/lib/gitlab/legacy_github_import/client.rb +++ b/lib/gitlab/legacy_github_import/client.rb @@ -80,7 +80,7 @@ module Gitlab if host.present? && api_version.present? "#{host}/api/#{api_version}" else - github_options[:site] + github_options[:site] || ::Octokit::Default.api_endpoint end end diff --git a/lib/gitlab/metrics/dashboard/stages/endpoint_inserter.rb b/lib/gitlab/metrics/dashboard/stages/endpoint_inserter.rb index 4f5e9a98799507e5d0e817983127653e681e74ec..e085f5519523b816d39be52fa92aa932dfac1f0f 100644 --- a/lib/gitlab/metrics/dashboard/stages/endpoint_inserter.rb +++ b/lib/gitlab/metrics/dashboard/stages/endpoint_inserter.rb @@ -16,7 +16,7 @@ module Gitlab private def endpoint_for_metric(metric) - if ENV['USE_SAMPLE_METRICS'] + if params[:sample_metrics] Gitlab::Routing.url_helpers.sample_metrics_project_environment_path( project, params[:environment], diff --git a/lib/gitlab/metrics/influx_db.rb b/lib/gitlab/metrics/influx_db.rb index 269d90fa971a50f5fd392c9c90369872e2486d7f..1f25257246152c2346cd0a62d63d63e147516afd 100644 --- a/lib/gitlab/metrics/influx_db.rb +++ b/lib/gitlab/metrics/influx_db.rb @@ -150,7 +150,7 @@ module Gitlab # Returns the prefix to use for the name of a series. def series_prefix - @series_prefix ||= Sidekiq.server? ? 'sidekiq_' : 'rails_' + @series_prefix ||= Gitlab::Runtime.sidekiq? ? 'sidekiq_' : 'rails_' end # Allow access from other metrics related middlewares diff --git a/lib/gitlab/metrics/samplers/influx_sampler.rb b/lib/gitlab/metrics/samplers/influx_sampler.rb index 1eae0a7bf4510fc2780bc4587ed42931c338b145..4e16e335bee7244be3b7f87072460844540b7cc9 100644 --- a/lib/gitlab/metrics/samplers/influx_sampler.rb +++ b/lib/gitlab/metrics/samplers/influx_sampler.rb @@ -39,14 +39,10 @@ module Gitlab end def add_metric(series, values, tags = {}) - prefix = sidekiq? ? 'sidekiq_' : 'rails_' + prefix = Gitlab::Runtime.sidekiq? ? 'sidekiq_' : 'rails_' @metrics << Metric.new("#{prefix}#{series}", values, tags) end - - def sidekiq? - Sidekiq.server? - end end end end diff --git a/lib/gitlab/metrics/samplers/unicorn_sampler.rb b/lib/gitlab/metrics/samplers/unicorn_sampler.rb index 355f938704e5ad23b8db1453083cd46fb16e05f7..8c4d150adad210099c93195a27110fb97191b77a 100644 --- a/lib/gitlab/metrics/samplers/unicorn_sampler.rb +++ b/lib/gitlab/metrics/samplers/unicorn_sampler.rb @@ -61,7 +61,7 @@ module Gitlab # it takes around 80ms. The instances of HttpServers are not a subject # to change so we can cache the list of servers. def http_servers - return [] unless defined?(::Unicorn::HttpServer) + return [] unless Gitlab::Runtime.unicorn? @http_servers ||= ObjectSpace.each_object(::Unicorn::HttpServer).to_a end diff --git a/lib/gitlab/metrics/subscribers/action_view.rb b/lib/gitlab/metrics/subscribers/action_view.rb index 2ed5878286ae44ecd6fe8a2158d0b036320fbc6b..5bd21b8e5d1672af0c6c614cfb7ca77a403efdef 100644 --- a/lib/gitlab/metrics/subscribers/action_view.rb +++ b/lib/gitlab/metrics/subscribers/action_view.rb @@ -36,7 +36,7 @@ module Gitlab end def relative_path(path) - path.gsub(%r{^#{Rails.root.to_s}/?}, '') + path.gsub(%r{^#{Rails.root}/?}, '') end def values_for(event) diff --git a/lib/gitlab/middleware/correlation_id.rb b/lib/gitlab/middleware/correlation_id.rb deleted file mode 100644 index fffd5da827fd88141909749edd6a0e4476490553..0000000000000000000000000000000000000000 --- a/lib/gitlab/middleware/correlation_id.rb +++ /dev/null @@ -1,31 +0,0 @@ -# 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) - ::Labkit::Correlation::CorrelationId.use_id(correlation_id(env)) do - @app.call(env) - end - end - - private - - def correlation_id(env) - request(env).request_id - end - - def request(env) - ActionDispatch::Request.new(env) - end - end - end -end diff --git a/lib/gitlab/middleware/request_context.rb b/lib/gitlab/middleware/request_context.rb new file mode 100644 index 0000000000000000000000000000000000000000..953423b371c20bdfa6718bcc3a39e21972788e87 --- /dev/null +++ b/lib/gitlab/middleware/request_context.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Gitlab + module Middleware + class RequestContext + def initialize(app) + @app = app + end + + def call(env) + # We should be using ActionDispatch::Request instead of + # Rack::Request to be consistent with Rails, but due to a Rails + # bug described in + # https://gitlab.com/gitlab-org/gitlab-foss/issues/58573#note_149799010 + # hosts behind a load balancer will only see 127.0.0.1 for the + # load balancer's IP. + req = Rack::Request.new(env) + + Gitlab::RequestContext.instance.client_ip = req.ip + Gitlab::RequestContext.instance.start_thread_cpu_time = Gitlab::Metrics::System.thread_cpu_time + Gitlab::RequestContext.instance.request_start_time = Gitlab::Metrics::System.real_time + + @app.call(env) + end + end + end +end diff --git a/lib/gitlab/multi_destination_logger.rb b/lib/gitlab/multi_destination_logger.rb new file mode 100644 index 0000000000000000000000000000000000000000..b6b19e81389c90e3b759b28cd639c294cae30e54 --- /dev/null +++ b/lib/gitlab/multi_destination_logger.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module Gitlab + class MultiDestinationLogger < ::Logger + def close + loggers.each(&:close) + end + + def self.debug(message) + loggers.each { |logger| logger.build.debug(message) } + end + + def self.error(message) + loggers.each { |logger| logger.build.error(message) } + end + + def self.warn(message) + loggers.each { |logger| logger.build.warn(message) } + end + + def self.info(message) + loggers.each { |logger| logger.build.info(message) } + end + + def self.read_latest + primary_logger.read_latest + end + + def self.file_name + primary_logger.file_name + end + + def self.full_log_path + primary_logger.full_log_path + end + + def self.file_name_noext + primary_logger.file_name_noext + end + + def self.loggers + raise NotImplementedError + end + + def self.primary_logger + raise NotImplementedError + end + end +end diff --git a/lib/gitlab/pages.rb b/lib/gitlab/pages.rb index 4899b1d32342c4b6089eef56080b795cd54b188f..c8cb8b6e02044bc13d9b64a8abc348d126b2a6cc 100644 --- a/lib/gitlab/pages.rb +++ b/lib/gitlab/pages.rb @@ -4,6 +4,7 @@ module Gitlab class Pages VERSION = File.read(Rails.root.join("GITLAB_PAGES_VERSION")).strip.freeze INTERNAL_API_REQUEST_HEADER = 'Gitlab-Pages-Api-Request'.freeze + MAX_SIZE = 1.terabyte include JwtAuthenticatable @@ -17,6 +18,11 @@ module Gitlab def secret_path Gitlab.config.pages.secret_file end + + def access_control_is_forced? + ::Gitlab.config.pages.access_control && + ::Gitlab::CurrentSettings.current_application_settings.force_pages_access_control + end end end end diff --git a/lib/gitlab/pagination/base.rb b/lib/gitlab/pagination/base.rb index 90fa1f8d1ec0a125cab6b0e493b772b2bd24c5da..a8a3397eba2a87e98a21406ebdddb3526609c65a 100644 --- a/lib/gitlab/pagination/base.rb +++ b/lib/gitlab/pagination/base.rb @@ -3,29 +3,12 @@ module Gitlab module Pagination class Base - private - - def per_page - @per_page ||= params[:per_page] - end - - def base_request_uri - @base_request_uri ||= URI.parse(request.url).tap do |uri| - uri.host = Gitlab.config.gitlab.host - uri.port = Gitlab.config.gitlab.port - end + def paginate(relation) + raise NotImplementedError end - def build_page_url(query_params:) - base_request_uri.tap do |uri| - uri.query = query_params - end.to_s - end - - def page_href(next_page_params = {}) - query_params = params.merge(**next_page_params, per_page: per_page).to_query - - build_page_url(query_params: query_params) + def finalize(records) + # Optional: Called with the actual set of records end end end diff --git a/lib/gitlab/pagination/keyset.rb b/lib/gitlab/pagination/keyset.rb index 5bd45fa9b56d7c2dc77b33d23dc453d48320ee7d..8692f30e1659f654b103a99cf1ca3899347d8fdf 100644 --- a/lib/gitlab/pagination/keyset.rb +++ b/lib/gitlab/pagination/keyset.rb @@ -3,10 +3,6 @@ module Gitlab module Pagination module Keyset - def self.paginate(request_context, relation) - Gitlab::Pagination::Keyset::Pager.new(request_context).paginate(relation) - end - def self.available?(request_context, relation) order_by = request_context.page.order_by diff --git a/lib/gitlab/pagination/keyset/page.rb b/lib/gitlab/pagination/keyset/page.rb index 735f54faf0fc031b4de6691571ae015c52be0248..8070512f97309fd6be04d479c0d418aaa8304a9b 100644 --- a/lib/gitlab/pagination/keyset/page.rb +++ b/lib/gitlab/pagination/keyset/page.rb @@ -11,14 +11,13 @@ module Gitlab # Maximum number of records for a page MAXIMUM_PAGE_SIZE = 100 - attr_accessor :lower_bounds, :end_reached + attr_accessor :lower_bounds attr_reader :order_by - def initialize(order_by: {}, lower_bounds: nil, per_page: DEFAULT_PAGE_SIZE, end_reached: false) + def initialize(order_by: {}, lower_bounds: nil, per_page: DEFAULT_PAGE_SIZE) @order_by = order_by.symbolize_keys @lower_bounds = lower_bounds&.symbolize_keys @per_page = per_page - @end_reached = end_reached end # Number of records to return per page @@ -28,17 +27,11 @@ module Gitlab [@per_page, MAXIMUM_PAGE_SIZE].min end - # Determine whether this page indicates the end of the collection - def end_reached? - @end_reached - end - # Construct a Page for the next page # Uses identical order_by/per_page information for the next page - def next(lower_bounds, end_reached) + def next(lower_bounds) dup.tap do |next_page| next_page.lower_bounds = lower_bounds&.symbolize_keys - next_page.end_reached = end_reached end end end diff --git a/lib/gitlab/pagination/keyset/pager.rb b/lib/gitlab/pagination/keyset/pager.rb index 99b125cc2a092715e2a214a690d0644ac1d6d236..6a2ae20f3b87445144e1c0eab89dcb1acae4ee09 100644 --- a/lib/gitlab/pagination/keyset/pager.rb +++ b/lib/gitlab/pagination/keyset/pager.rb @@ -3,7 +3,7 @@ module Gitlab module Pagination module Keyset - class Pager + class Pager < Gitlab::Pagination::Base attr_reader :request def initialize(request) @@ -14,27 +14,20 @@ module Gitlab # Validate assumption: The last two columns must match the page order_by validate_order!(relation) - # This performs the database query and retrieves records - # We retrieve one record more to check if we have data beyond this page - all_records = relation.limit(page.per_page + 1).to_a # rubocop: disable CodeReuse/ActiveRecord - - records_for_page = all_records.first(page.per_page) - - # If we retrieved more records than belong on this page, - # we know there's a next page - there_is_more = all_records.size > records_for_page.size - apply_headers(records_for_page.last, there_is_more) + relation.limit(page.per_page) # rubocop: disable CodeReuse/ActiveRecord + end - records_for_page + def finalize(records) + apply_headers(records.last) end private - def apply_headers(last_record_in_page, there_is_more) - end_reached = last_record_in_page.nil? || !there_is_more - lower_bounds = last_record_in_page&.slice(page.order_by.keys) + def apply_headers(last_record_in_page) + return unless last_record_in_page - next_page = page.next(lower_bounds, end_reached) + lower_bounds = last_record_in_page&.slice(page.order_by.keys) + next_page = page.next(lower_bounds) request.apply_headers(next_page) end diff --git a/lib/gitlab/pagination/keyset/request_context.rb b/lib/gitlab/pagination/keyset/request_context.rb index aeaed7587b3809a32bc549c3e6a2ba677cdbc562..8c8138b307647a20602dfb0ac8b75597e2ff793c 100644 --- a/lib/gitlab/pagination/keyset/request_context.rb +++ b/lib/gitlab/pagination/keyset/request_context.rb @@ -68,8 +68,6 @@ module Gitlab end def pagination_links(next_page) - return if next_page.end_reached? - %(<#{page_href(next_page)}>; rel="next") end diff --git a/lib/gitlab/pagination/offset_pagination.rb b/lib/gitlab/pagination/offset_pagination.rb index bf31f252a6bbc69309661a41ea98f9987d47e112..11a5ef4e518e034d7aaf33012102a4c8a847fc8c 100644 --- a/lib/gitlab/pagination/offset_pagination.rb +++ b/lib/gitlab/pagination/offset_pagination.rb @@ -72,6 +72,29 @@ module Gitlab def data_without_counts?(paginated_data) paginated_data.is_a?(Kaminari::PaginatableWithoutCount) end + + def base_request_uri + @base_request_uri ||= URI.parse(request.url).tap do |uri| + uri.host = Gitlab.config.gitlab.host + uri.port = Gitlab.config.gitlab.port + end + end + + def build_page_url(query_params:) + base_request_uri.tap do |uri| + uri.query = query_params + end.to_s + end + + def page_href(next_page_params = {}) + query_params = params.merge(**next_page_params, per_page: per_page).to_query + + build_page_url(query_params: query_params) + end + + def per_page + @per_page ||= params[:per_page] + end end end end diff --git a/lib/gitlab/patch/action_dispatch_journey_formatter.rb b/lib/gitlab/patch/action_dispatch_journey_formatter.rb new file mode 100644 index 0000000000000000000000000000000000000000..2d3b7bb99232fd1340cb7cf947a69bb8aa71e1f2 --- /dev/null +++ b/lib/gitlab/patch/action_dispatch_journey_formatter.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Gitlab + module Patch + module ActionDispatchJourneyFormatter + def self.prepended(mod) + mod.alias_method(:old_missing_keys, :missing_keys) + mod.remove_method(:missing_keys) + end + + private + + def missing_keys(route, parts) + missing_keys = nil + tests = route.path.requirements_for_missing_keys_check + route.required_parts.each do |key| + case tests[key] + when nil + unless parts[key] + missing_keys ||= [] + missing_keys << key + end + else + unless tests[key].match?(parts[key]) + missing_keys ||= [] + missing_keys << key + end + end + end + missing_keys + end + end + end +end diff --git a/lib/gitlab/profiler.rb b/lib/gitlab/profiler.rb index f2f6180c464e555691510570793a82db9d99e03c..f47ccb8fed98a02e7a25f2a10a5ad46166a58513 100644 --- a/lib/gitlab/profiler.rb +++ b/lib/gitlab/profiler.rb @@ -107,7 +107,7 @@ module Gitlab super - Gitlab::Profiler.clean_backtrace(caller).each do |caller_line| + Gitlab::BacktraceCleaner.clean_backtrace(caller).each do |caller_line| stripped_caller_line = caller_line.sub("#{Rails.root}/", '') super(" ↳ #{stripped_caller_line}") @@ -117,14 +117,6 @@ module Gitlab end end - def self.clean_backtrace(backtrace) - return unless backtrace - - Array(Rails.backtrace_cleaner.clean(backtrace)).reject do |line| - line.match(Regexp.union(IGNORE_BACKTRACES)) - end - end - def self.with_custom_logger(logger) original_colorize_logging = ActiveSupport::LogSubscriber.colorize_logging original_activerecord_logger = ActiveRecord::Base.logger diff --git a/lib/gitlab/project_authorizations.rb b/lib/gitlab/project_authorizations.rb index 4e5e2d4a6a96c90300e6e7a7d2ae25f1b19fb240..e2271b1492c4ca57792766b5652134cafa40933c 100644 --- a/lib/gitlab/project_authorizations.rb +++ b/lib/gitlab/project_authorizations.rb @@ -68,7 +68,7 @@ module Gitlab .select([namespaces[:id], members[:access_level]]) .except(:order) - if Feature.enabled?(:share_group_with_group) + if Feature.enabled?(:share_group_with_group, default_enabled: true) # Namespaces shared with any of the group cte << Group.select([namespaces[:id], 'group_group_links.group_access AS access_level']) .joins(join_group_group_links) diff --git a/lib/gitlab/prometheus/adapter.rb b/lib/gitlab/prometheus/adapter.rb new file mode 100644 index 0000000000000000000000000000000000000000..ed10ef2917ffc618dd2db0f4ad0d1ca1c444c1ae --- /dev/null +++ b/lib/gitlab/prometheus/adapter.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Gitlab + module Prometheus + class Adapter + attr_reader :project, :cluster + + def initialize(project, cluster) + @project = project + @cluster = cluster + end + + def prometheus_adapter + @prometheus_adapter ||= if service_prometheus_adapter.can_query? + service_prometheus_adapter + else + cluster_prometheus_adapter + end + end + + def cluster_prometheus_adapter + application = cluster&.application_prometheus + + application if application&.available? + end + + private + + def service_prometheus_adapter + project.find_or_initialize_service('prometheus') + end + end + end +end diff --git a/lib/gitlab/push_options.rb b/lib/gitlab/push_options.rb index 333f848df9b627ac461e2ad4162382ccac2d04fc..02446a7953baa3f0a6a8b3527f9453f125a05693 100644 --- a/lib/gitlab/push_options.rb +++ b/lib/gitlab/push_options.rb @@ -32,6 +32,8 @@ module Gitlab OPTION_MATCHER = /(?<namespace>[^\.]+)\.(?<key>[^=]+)=?(?<value>.*)/.freeze + CI_SKIP = 'ci.skip' + attr_reader :options def initialize(options = []) diff --git a/lib/gitlab/quick_actions/command_definition.rb b/lib/gitlab/quick_actions/command_definition.rb index ebdae139315680ec48d6b9a9909d40d711925d4f..b17a0208f953ff98cecacbde4716dd5f14af683a 100644 --- a/lib/gitlab/quick_actions/command_definition.rb +++ b/lib/gitlab/quick_actions/command_definition.rb @@ -4,7 +4,7 @@ module Gitlab module QuickActions class CommandDefinition attr_accessor :name, :aliases, :description, :explanation, :execution_message, - :params, :condition_block, :parse_params_block, :action_block, :warning, :types + :params, :condition_block, :parse_params_block, :action_block, :warning, :icon, :types def initialize(name, attributes = {}) @name = name @@ -12,6 +12,7 @@ module Gitlab @aliases = attributes[:aliases] || [] @description = attributes[:description] || '' @warning = attributes[:warning] || '' + @icon = attributes[:icon] || '' @explanation = attributes[:explanation] || '' @execution_message = attributes[:execution_message] || '' @params = attributes[:params] || [] @@ -45,7 +46,13 @@ module Gitlab explanation end - warning.empty? ? message : "#{message} (#{warning})" + warning_text = if warning.respond_to?(:call) + execute_block(warning, context, arg) + else + warning + end + + warning.empty? ? message : "#{message} (#{warning_text})" end def execute(context, arg) @@ -72,6 +79,11 @@ module Gitlab desc = context.instance_exec(&desc) rescue '' end + warn = warning + if warn.respond_to?(:call) + warn = context.instance_exec(&warn) rescue '' + end + prms = params if prms.respond_to?(:call) prms = Array(context.instance_exec(&prms)) rescue params @@ -81,7 +93,8 @@ module Gitlab name: name, aliases: aliases, description: desc, - warning: warning, + warning: warn, + icon: icon, params: prms } end diff --git a/lib/gitlab/quick_actions/dsl.rb b/lib/gitlab/quick_actions/dsl.rb index 5abbd377642e49501c4f5181ce50aaaf4d59ad71..a2dfcc6de9a4dcf581055cae48409c5852a7a306 100644 --- a/lib/gitlab/quick_actions/dsl.rb +++ b/lib/gitlab/quick_actions/dsl.rb @@ -33,8 +33,12 @@ module Gitlab @description = block_given? ? block : text end - def warning(message = '') - @warning = message + def warning(text = '', &block) + @warning = block_given? ? block : text + end + + def icon(string = '') + @icon = string end # Allows to define params for the next quick action. @@ -192,6 +196,7 @@ module Gitlab aliases: aliases, description: @description, warning: @warning, + icon: @icon, explanation: @explanation, execution_message: @execution_message, params: @params, @@ -213,6 +218,7 @@ module Gitlab @params = nil @condition_block = nil @warning = nil + @icon = nil @parse_params_block = nil @types = nil end diff --git a/lib/gitlab/redis/wrapper.rb b/lib/gitlab/redis/wrapper.rb index 412d00c69394f669d0ee5e512bb6511a3d076cd9..c8932b269254c9638db077987e3edf07ac6e9931 100644 --- a/lib/gitlab/redis/wrapper.rb +++ b/lib/gitlab/redis/wrapper.rb @@ -22,11 +22,8 @@ module Gitlab def pool_size # heuristic constant 5 should be a config setting somewhere -- related to CPU count? size = 5 - if Sidekiq.server? - # the pool will be used in a multi-threaded context - size += Sidekiq.options[:concurrency] - elsif defined?(::Puma) - size += Puma.cli_config.options[:max_threads] + if Gitlab::Runtime.multi_threaded? + size += Gitlab::Runtime.max_threads end size diff --git a/lib/gitlab/regex.rb b/lib/gitlab/regex.rb index d9300da38a527631f83c433e96ad7fc68f9a0444..48eaf073e8a3df6589155986207d30b0c931bb8a 100644 --- a/lib/gitlab/regex.rb +++ b/lib/gitlab/regex.rb @@ -5,7 +5,7 @@ module Gitlab extend self def project_name_regex - @project_name_regex ||= /\A[\p{Alnum}\u{00A9}-\u{1f9c0}_][\p{Alnum}\p{Pd}\u{00A9}-\u{1f9c0}_\. ]*\z/.freeze + @project_name_regex ||= /\A[\p{Alnum}\u{00A9}-\u{1f9ff}_][\p{Alnum}\p{Pd}\u{00A9}-\u{1f9ff}_\. ]*\z/.freeze end def project_name_regex_message diff --git a/lib/gitlab/repo_path.rb b/lib/gitlab/repo_path.rb index 030e50dfbf6f226cc0a1632f33d2e7aff6f02117..1baa2a9e4615c436f61a71e63cdcc3a805b2b42b 100644 --- a/lib/gitlab/repo_path.rb +++ b/lib/gitlab/repo_path.rb @@ -32,9 +32,12 @@ module Gitlab def self.find_project(project_path) project = Project.find_by_full_path(project_path, follow_redirects: true) - was_redirected = project && project.full_path.casecmp(project_path) != 0 - [project, was_redirected] + [project, redirected?(project, project_path)] + end + + def self.redirected?(project, project_path) + project && project.full_path.casecmp(project_path) != 0 end end end diff --git a/lib/gitlab/repository_cache.rb b/lib/gitlab/repository_cache.rb index 56007574b1b75fb11974993f74a0f46ac65ce49c..fca8c43da2ea5dee544a87f03642735286e97729 100644 --- a/lib/gitlab/repository_cache.rb +++ b/lib/gitlab/repository_cache.rb @@ -7,7 +7,8 @@ module Gitlab def initialize(repository, extra_namespace: nil, backend: Rails.cache) @repository = repository - @namespace = "#{repository.full_path}:#{repository.project.id}" + @namespace = "#{repository.full_path}" + @namespace += ":#{repository.project.id}" if repository.project @namespace = "#{@namespace}:#{extra_namespace}" if extra_namespace @backend = backend end diff --git a/lib/gitlab/repository_set_cache.rb b/lib/gitlab/repository_set_cache.rb index 6d3ac53a787a38062d17fcc16797f14ded1e1aea..4797ec0b1169f72ca5656b16ff1b5627cbb704e4 100644 --- a/lib/gitlab/repository_set_cache.rb +++ b/lib/gitlab/repository_set_cache.rb @@ -7,7 +7,8 @@ module Gitlab def initialize(repository, extra_namespace: nil, expires_in: 2.weeks) @repository = repository - @namespace = "#{repository.full_path}:#{repository.project.id}" + @namespace = "#{repository.full_path}" + @namespace += ":#{repository.project.id}" if repository.project @namespace = "#{@namespace}:#{extra_namespace}" if extra_namespace @expires_in = expires_in end diff --git a/lib/gitlab/request_context.rb b/lib/gitlab/request_context.rb index 13187836e025047eef9f4e4200f6a41b0bb85aa1..214670cac280ba866259c4ff7cf1b4741adbff7f 100644 --- a/lib/gitlab/request_context.rb +++ b/lib/gitlab/request_context.rb @@ -2,34 +2,40 @@ module Gitlab class RequestContext - class << self - def client_ip - Gitlab::SafeRequestStore[:client_ip] - end + include Gitlab::Utils::StrongMemoize + include Singleton + + RequestDeadlineExceeded = Class.new(StandardError) + + attr_accessor :client_ip, :start_thread_cpu_time, :request_start_time - def start_thread_cpu_time - Gitlab::SafeRequestStore[:start_thread_cpu_time] + class << self + def instance + Gitlab::SafeRequestStore[:request_context] ||= new end end - def initialize(app) - @app = app + def request_deadline + strong_memoize(:request_deadline) do + next unless request_start_time + next unless Feature.enabled?(:request_deadline) + + request_start_time + max_request_duration_seconds + end end - def call(env) - # We should be using ActionDispatch::Request instead of - # Rack::Request to be consistent with Rails, but due to a Rails - # bug described in - # https://gitlab.com/gitlab-org/gitlab-foss/issues/58573#note_149799010 - # hosts behind a load balancer will only see 127.0.0.1 for the - # load balancer's IP. - req = Rack::Request.new(env) + def ensure_deadline_not_exceeded! + return unless request_deadline + return if Gitlab::Metrics::System.real_time < request_deadline - Gitlab::SafeRequestStore[:client_ip] = req.ip + raise RequestDeadlineExceeded, + "Request takes longer than #{max_request_duration_seconds}" + end - Gitlab::SafeRequestStore[:start_thread_cpu_time] = Gitlab::Metrics::System.thread_cpu_time + private - @app.call(env) + def max_request_duration_seconds + Settings.gitlab.max_request_duration_seconds end end end diff --git a/lib/gitlab/runtime.rb b/lib/gitlab/runtime.rb new file mode 100644 index 0000000000000000000000000000000000000000..97f7a8e28006c6bb9d9ee3194ac9737fdfb1f38b --- /dev/null +++ b/lib/gitlab/runtime.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +module Gitlab + # Provides routines to identify the current runtime as which the application + # executes, such as whether it is an application server and which one. + module Runtime + IdentificationError = Class.new(RuntimeError) + AmbiguousProcessError = Class.new(IdentificationError) + UnknownProcessError = Class.new(IdentificationError) + + class << self + def identify + matches = [] + matches << :puma if puma? + matches << :unicorn if unicorn? + matches << :console if console? + matches << :sidekiq if sidekiq? + matches << :rake if rake? + matches << :rspec if rspec? + + if matches.one? + matches.first + elsif matches.none? + raise UnknownProcessError.new( + "Failed to identify runtime for process #{Process.pid} (#{$0})" + ) + else + raise AmbiguousProcessError.new( + "Ambiguous runtime #{matches} for process #{Process.pid} (#{$0})" + ) + end + end + + def puma? + !!defined?(::Puma) + end + + # For unicorn, we need to check for actual server instances to avoid false positives. + def unicorn? + !!(defined?(::Unicorn) && defined?(::Unicorn::HttpServer)) + end + + def sidekiq? + !!(defined?(::Sidekiq) && Sidekiq.server?) + end + + def rake? + !!(defined?(::Rake) && Rake.application.top_level_tasks.any?) + end + + def rspec? + Rails.env.test? && process_name == 'rspec' + end + + def console? + !!defined?(::Rails::Console) + end + + def web_server? + puma? || unicorn? + end + + def multi_threaded? + puma? || sidekiq? + end + + def process_name + File.basename($0) + end + + def max_threads + if puma? + Puma.cli_config.options[:max_threads] + elsif sidekiq? + Sidekiq.options[:concurrency] + else + 1 + end + end + end + end +end diff --git a/lib/gitlab/sherlock/file_sample.rb b/lib/gitlab/sherlock/file_sample.rb index 604b6df12cc2d13133e0c0198a5f976b271901c6..5d10d8c487762a858668190c62a859ec65667231 100644 --- a/lib/gitlab/sherlock/file_sample.rb +++ b/lib/gitlab/sherlock/file_sample.rb @@ -18,7 +18,7 @@ module Gitlab end def relative_path - @relative_path ||= @file.gsub(%r{^#{Rails.root.to_s}/?}, '') + @relative_path ||= @file.gsub(%r{^#{Rails.root}/?}, '') end def to_param diff --git a/lib/gitlab/sherlock/line_profiler.rb b/lib/gitlab/sherlock/line_profiler.rb index 209ba784f9cd2525bfdc308c4d91c1b372091d2b..52d88f074b70e4e9a16c00fa17941f9538a72def 100644 --- a/lib/gitlab/sherlock/line_profiler.rb +++ b/lib/gitlab/sherlock/line_profiler.rb @@ -45,7 +45,7 @@ module Gitlab require 'rblineprof' retval = nil - samples = lineprof(/^#{Rails.root.to_s}/) { retval = yield } + samples = lineprof(/^#{Rails.root}/) { retval = yield } file_samples = aggregate_rblineprof(samples) diff --git a/lib/gitlab/sidekiq_config.rb b/lib/gitlab/sidekiq_config.rb index ffceeb68f20e1bd7878e38f90896778bc9b07b18..b246c507e9e7eda4bcf4fa4ffd2e6efbd5e67038 100644 --- a/lib/gitlab/sidekiq_config.rb +++ b/lib/gitlab/sidekiq_config.rb @@ -1,78 +1,53 @@ # frozen_string_literal: true require 'yaml' -require 'set' module Gitlab module SidekiqConfig - QUEUE_CONFIG_PATHS = begin - result = %w[app/workers/all_queues.yml] - result << 'ee/app/workers/all_queues.yml' if Gitlab.ee? - result - end.freeze + class << self + include Gitlab::SidekiqConfig::CliMethods - # This method is called by `ee/bin/sidekiq-cluster` in EE, which runs outside - # of bundler/Rails context, so we cannot use any gem or Rails methods. - def self.worker_queues(rails_path = Rails.root.to_s) - @worker_queues ||= {} - - @worker_queues[rails_path] ||= QUEUE_CONFIG_PATHS.flat_map do |path| - full_path = File.join(rails_path, path) - - File.exist?(full_path) ? YAML.load_file(full_path) : [] + def redis_queues + # Not memoized, because this can change during the life of the application + Sidekiq::Queue.all.map(&:name) end - end - - # This method is called by `ee/bin/sidekiq-cluster` in EE, which runs outside - # of bundler/Rails context, so we cannot use any gem or Rails methods. - def self.expand_queues(queues, all_queues = self.worker_queues) - return [] if queues.empty? - queues_set = all_queues.to_set - - queues.flat_map do |queue| - [queue, *queues_set.grep(/\A#{queue}:/)] + def config_queues + @config_queues ||= begin + config = YAML.load_file(Rails.root.join('config/sidekiq_queues.yml')) + config[:queues].map(&:first) + end end - end - def self.redis_queues - # Not memoized, because this can change during the life of the application - Sidekiq::Queue.all.map(&:name) - end + def cron_workers + @cron_workers ||= Settings.cron_jobs.map { |job_name, options| options['job_class'].constantize } + end - def self.config_queues - @config_queues ||= begin - config = YAML.load_file(Rails.root.join('config/sidekiq_queues.yml')) - config[:queues].map(&:first) + def workers + @workers ||= begin + result = find_workers(Rails.root.join('app', 'workers')) + result.concat(find_workers(Rails.root.join('ee', 'app', 'workers'))) if Gitlab.ee? + result + end end - end - def self.cron_workers - @cron_workers ||= Settings.cron_jobs.map { |job_name, options| options['job_class'].constantize } - end + private - def self.workers - @workers ||= begin - result = find_workers(Rails.root.join('app', 'workers')) - result.concat(find_workers(Rails.root.join('ee', 'app', 'workers'))) if Gitlab.ee? - result - end - end + def find_workers(root) + concerns = root.join('concerns').to_s - def self.find_workers(root) - concerns = root.join('concerns').to_s + workers = Dir[root.join('**', '*.rb')] + .reject { |path| path.start_with?(concerns) } - workers = Dir[root.join('**', '*.rb')] - .reject { |path| path.start_with?(concerns) } + workers.map! do |path| + ns = Pathname.new(path).relative_path_from(root).to_s.gsub('.rb', '') - workers.map! do |path| - ns = Pathname.new(path).relative_path_from(root).to_s.gsub('.rb', '') + ns.camelize.constantize + end - ns.camelize.constantize + # Skip things that aren't workers + workers.select { |w| w < Sidekiq::Worker } end - - # Skip things that aren't workers - workers.select { |w| w < Sidekiq::Worker } end end end diff --git a/lib/gitlab/sidekiq_config/cli_methods.rb b/lib/gitlab/sidekiq_config/cli_methods.rb new file mode 100644 index 0000000000000000000000000000000000000000..1ce46289e819b036e5d16e96d09d40662557b54f --- /dev/null +++ b/lib/gitlab/sidekiq_config/cli_methods.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require 'yaml' +require 'set' + +# These methods are called by `sidekiq-cluster`, which runs outside of +# the bundler/Rails context, so we cannot use any gem or Rails methods. +module Gitlab + module SidekiqConfig + module CliMethods + # The methods in this module are used as module methods + # rubocop:disable Gitlab/ModuleWithInstanceVariables + extend self + + QUEUE_CONFIG_PATHS = begin + result = %w[app/workers/all_queues.yml] + result << 'ee/app/workers/all_queues.yml' if Gitlab.ee? + result + end.freeze + + def worker_queues(rails_path = Rails.root.to_s) + @worker_queues ||= {} + + @worker_queues[rails_path] ||= QUEUE_CONFIG_PATHS.flat_map do |path| + full_path = File.join(rails_path, path) + + File.exist?(full_path) ? YAML.load_file(full_path) : [] + end + end + + def expand_queues(queues, all_queues = self.worker_queues) + return [] if queues.empty? + + queues_set = all_queues.to_set + + queues.flat_map do |queue| + [queue, *queues_set.grep(/\A#{queue}:/)] + end + end + # rubocop:enable Gitlab/ModuleWithInstanceVariables + end + end +end diff --git a/lib/gitlab/sidekiq_logging/exception_handler.rb b/lib/gitlab/sidekiq_logging/exception_handler.rb index fba74b6c9ed8987823248f0880c018383e05b250..a6d6819bf8eebef974333401f255dd68057170f7 100644 --- a/lib/gitlab/sidekiq_logging/exception_handler.rb +++ b/lib/gitlab/sidekiq_logging/exception_handler.rb @@ -18,7 +18,7 @@ module Gitlab data.merge!(job_data) if job_data.present? end - data[:error_backtrace] = Gitlab::Profiler.clean_backtrace(job_exception.backtrace) if job_exception.backtrace.present? + data[:error_backtrace] = Gitlab::BacktraceCleaner.clean_backtrace(job_exception.backtrace) if job_exception.backtrace.present? Sidekiq.logger.warn(data) end diff --git a/lib/gitlab/sidekiq_logging/json_formatter.rb b/lib/gitlab/sidekiq_logging/json_formatter.rb index 88888c5994ea0dff07d075985df6b4b9d8010679..e0b0d684bea81bb3157c57ce15944f4cbb48f699 100644 --- a/lib/gitlab/sidekiq_logging/json_formatter.rb +++ b/lib/gitlab/sidekiq_logging/json_formatter.rb @@ -3,6 +3,8 @@ module Gitlab module SidekiqLogging class JSONFormatter + TIMESTAMP_FIELDS = %w[created_at enqueued_at started_at retried_at failed_at completed_at].freeze + def call(severity, timestamp, progname, data) output = { severity: severity, @@ -13,11 +15,27 @@ module Gitlab when String output[:message] = data when Hash + convert_to_iso8601!(data) output.merge!(data) end output.to_json + "\n" end + + private + + def convert_to_iso8601!(payload) + TIMESTAMP_FIELDS.each do |key| + value = payload[key] + payload[key] = format_time(value) if value.present? + end + end + + def format_time(timestamp) + return timestamp unless timestamp.is_a?(Numeric) + + Time.at(timestamp).utc.iso8601(3) + end end end end diff --git a/lib/gitlab/sidekiq_logging/structured_logger.rb b/lib/gitlab/sidekiq_logging/structured_logger.rb index ca9e3b8428cc32cf902da1d2500d85015a51c0da..8e7626b8eb6ac7f7f6ab8b6449171a9098ddd138 100644 --- a/lib/gitlab/sidekiq_logging/structured_logger.rb +++ b/lib/gitlab/sidekiq_logging/structured_logger.rb @@ -1,15 +1,17 @@ # frozen_string_literal: true +require 'active_record' +require 'active_record/log_subscriber' + module Gitlab module SidekiqLogging class StructuredLogger - START_TIMESTAMP_FIELDS = %w[created_at enqueued_at].freeze - DONE_TIMESTAMP_FIELDS = %w[started_at retried_at failed_at completed_at].freeze MAXIMUM_JOB_ARGUMENTS_LENGTH = 10.kilobytes def call(job, queue) started_time = get_time base_payload = parse_job(job) + ActiveRecord::LogSubscriber.reset_runtime Sidekiq.logger.info log_job_start(base_payload) @@ -61,7 +63,8 @@ module Gitlab payload['job_status'] = 'done' end - convert_to_iso8601(payload, DONE_TIMESTAMP_FIELDS) + payload['db_duration'] = ActiveRecord::LogSubscriber.runtime + payload['db_duration_s'] = payload['db_duration'] / 1000 payload end @@ -72,7 +75,7 @@ module Gitlab # ignore `cpu_s` if the platform does not support Process::CLOCK_THREAD_CPUTIME_ID (time[:cputime] == 0) # supported OS version can be found at: https://www.rubydoc.info/stdlib/core/2.1.6/Process:clock_gettime payload['cpu_s'] = time[:cputime].round(6) if time[:cputime] > 0 - payload['completed_at'] = Time.now.utc + payload['completed_at'] = Time.now.utc.to_f end def parse_job(job) @@ -84,17 +87,9 @@ module Gitlab job.delete('args') unless ENV['SIDEKIQ_LOG_ARGUMENTS'] job['args'] = limited_job_args(job['args']) if job['args'] - convert_to_iso8601(job, START_TIMESTAMP_FIELDS) - job end - def convert_to_iso8601(payload, keys) - keys.each do |key| - payload[key] = format_time(payload[key]) if payload[key] - end - end - def elapsed(t0) t1 = get_time { @@ -114,12 +109,6 @@ module Gitlab Gitlab::Metrics::System.monotonic_time end - def format_time(timestamp) - return timestamp if timestamp.is_a?(String) - - Time.at(timestamp).utc.iso8601(6) - end - def limited_job_args(args) return unless args.is_a?(Array) diff --git a/lib/gitlab/sidekiq_middleware.rb b/lib/gitlab/sidekiq_middleware.rb index c6726dcfa675c8c657ec13abb33b622ad84b6f88..3dda244233f5df81f9a6fda2edb74edabf995388 100644 --- a/lib/gitlab/sidekiq_middleware.rb +++ b/lib/gitlab/sidekiq_middleware.rb @@ -10,12 +10,12 @@ module Gitlab def self.server_configurator(metrics: true, arguments_logger: true, memory_killer: true, request_store: true) lambda do |chain| chain.add Gitlab::SidekiqMiddleware::Monitor - chain.add Gitlab::SidekiqMiddleware::Metrics if metrics + chain.add Gitlab::SidekiqMiddleware::ServerMetrics if metrics chain.add Gitlab::SidekiqMiddleware::ArgumentsLogger if arguments_logger chain.add Gitlab::SidekiqMiddleware::MemoryKiller if memory_killer chain.add Gitlab::SidekiqMiddleware::RequestStoreMiddleware if request_store chain.add Gitlab::SidekiqMiddleware::BatchLoader - chain.add Gitlab::SidekiqMiddleware::CorrelationLogger + chain.add Labkit::Middleware::Sidekiq::Server chain.add Gitlab::SidekiqMiddleware::InstrumentationLogger chain.add Gitlab::SidekiqStatus::ServerMiddleware end @@ -27,7 +27,8 @@ module Gitlab def self.client_configurator lambda do |chain| chain.add Gitlab::SidekiqStatus::ClientMiddleware - chain.add Gitlab::SidekiqMiddleware::CorrelationInjector + chain.add Gitlab::SidekiqMiddleware::ClientMetrics + chain.add Labkit::Middleware::Sidekiq::Client end end end diff --git a/lib/gitlab/sidekiq_middleware/client_metrics.rb b/lib/gitlab/sidekiq_middleware/client_metrics.rb new file mode 100644 index 0000000000000000000000000000000000000000..cd11415b55ec1a2ef772ef9b2c59140f815efff5 --- /dev/null +++ b/lib/gitlab/sidekiq_middleware/client_metrics.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Gitlab + module SidekiqMiddleware + class ClientMetrics < SidekiqMiddleware::Metrics + ENQUEUED = :sidekiq_enqueued_jobs_total + + def initialize + @metrics = init_metrics + end + + def call(worker, _job, queue, _redis_pool) + labels = create_labels(worker.class, queue) + + @metrics.fetch(ENQUEUED).increment(labels, 1) + + yield + end + + private + + def init_metrics + { + ENQUEUED => ::Gitlab::Metrics.counter(ENQUEUED, 'Sidekiq jobs enqueued') + } + end + end + end +end diff --git a/lib/gitlab/sidekiq_middleware/correlation_injector.rb b/lib/gitlab/sidekiq_middleware/correlation_injector.rb deleted file mode 100644 index 1539fd706abe64f67f9b23c0284d088dcb5f6572..0000000000000000000000000000000000000000 --- a/lib/gitlab/sidekiq_middleware/correlation_injector.rb +++ /dev/null @@ -1,14 +0,0 @@ -# frozen_string_literal: true - -module Gitlab - module SidekiqMiddleware - class CorrelationInjector - def call(worker_class, job, queue, redis_pool) - job[Labkit::Correlation::CorrelationId::LOG_KEY] ||= - Labkit::Correlation::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 deleted file mode 100644 index cffc44835738260a32ab2366d22c06a4772752ec..0000000000000000000000000000000000000000 --- a/lib/gitlab/sidekiq_middleware/correlation_logger.rb +++ /dev/null @@ -1,15 +0,0 @@ -# frozen_string_literal: true - -module Gitlab - module SidekiqMiddleware - class CorrelationLogger - def call(worker, job, queue) - correlation_id = job[Labkit::Correlation::CorrelationId::LOG_KEY] - - Labkit::Correlation::CorrelationId.use_id(correlation_id) do - yield - end - end - end - end -end diff --git a/lib/gitlab/sidekiq_middleware/metrics.rb b/lib/gitlab/sidekiq_middleware/metrics.rb index 7bfb0d54d80614fadf7c9f4d1efc0be9e1ad96c5..9588e9ef19a8f7cc96ba475bc98e8e8854a9806d 100644 --- a/lib/gitlab/sidekiq_middleware/metrics.rb +++ b/lib/gitlab/sidekiq_middleware/metrics.rb @@ -3,68 +3,11 @@ module Gitlab module SidekiqMiddleware class Metrics - # SIDEKIQ_LATENCY_BUCKETS are latency histogram buckets better suited to Sidekiq - # timeframes than the DEFAULT_BUCKET definition. Defined in seconds. - SIDEKIQ_LATENCY_BUCKETS = [0.1, 0.25, 0.5, 1, 2.5, 5, 10, 60, 300, 600].freeze - TRUE_LABEL = "yes" FALSE_LABEL = "no" - def initialize - @metrics = init_metrics - - @metrics[:sidekiq_concurrency].set({}, Sidekiq.options[:concurrency].to_i) - end - - def call(worker, job, queue) - labels = create_labels(worker.class, queue) - queue_duration = ::Gitlab::InstrumentationHelper.queue_duration_for_job(job) - - @metrics[:sidekiq_jobs_queue_duration_seconds].observe(labels, queue_duration) if queue_duration - @metrics[:sidekiq_running_jobs].increment(labels, 1) - - if job['retry_count'].present? - @metrics[:sidekiq_jobs_retried_total].increment(labels, 1) - end - - job_succeeded = false - monotonic_time_start = Gitlab::Metrics::System.monotonic_time - job_thread_cputime_start = get_thread_cputime - begin - yield - job_succeeded = true - ensure - monotonic_time_end = Gitlab::Metrics::System.monotonic_time - job_thread_cputime_end = get_thread_cputime - - monotonic_time = monotonic_time_end - monotonic_time_start - job_thread_cputime = job_thread_cputime_end - job_thread_cputime_start - - # sidekiq_running_jobs, sidekiq_jobs_failed_total should not include the job_status label - @metrics[:sidekiq_running_jobs].increment(labels, -1) - @metrics[:sidekiq_jobs_failed_total].increment(labels, 1) unless job_succeeded - - # job_status: done, fail match the job_status attribute in structured logging - labels[:job_status] = job_succeeded ? "done" : "fail" - @metrics[:sidekiq_jobs_cpu_seconds].observe(labels, job_thread_cputime) - @metrics[:sidekiq_jobs_completion_seconds].observe(labels, monotonic_time) - end - end - private - def init_metrics - { - sidekiq_jobs_cpu_seconds: ::Gitlab::Metrics.histogram(:sidekiq_jobs_cpu_seconds, 'Seconds of cpu time to run Sidekiq job', {}, SIDEKIQ_LATENCY_BUCKETS), - sidekiq_jobs_completion_seconds: ::Gitlab::Metrics.histogram(:sidekiq_jobs_completion_seconds, 'Seconds to complete Sidekiq job', {}, SIDEKIQ_LATENCY_BUCKETS), - sidekiq_jobs_queue_duration_seconds: ::Gitlab::Metrics.histogram(:sidekiq_jobs_queue_duration_seconds, 'Duration in seconds that a Sidekiq job was queued before being executed', {}, SIDEKIQ_LATENCY_BUCKETS), - sidekiq_jobs_failed_total: ::Gitlab::Metrics.counter(:sidekiq_jobs_failed_total, 'Sidekiq jobs failed'), - sidekiq_jobs_retried_total: ::Gitlab::Metrics.counter(:sidekiq_jobs_retried_total, 'Sidekiq jobs retried'), - sidekiq_running_jobs: ::Gitlab::Metrics.gauge(:sidekiq_running_jobs, 'Number of Sidekiq jobs running', {}, :all), - sidekiq_concurrency: ::Gitlab::Metrics.gauge(:sidekiq_concurrency, 'Maximum number of Sidekiq jobs', {}, :all) - } - end - def create_labels(worker_class, queue) labels = { queue: queue.to_s, latency_sensitive: FALSE_LABEL, external_dependencies: FALSE_LABEL, feature_category: "", boundary: "" } return labels unless worker_class.include? WorkerAttributes @@ -84,10 +27,6 @@ module Gitlab def bool_as_label(value) value ? TRUE_LABEL : FALSE_LABEL end - - def get_thread_cputime - defined?(Process::CLOCK_THREAD_CPUTIME_ID) ? Process.clock_gettime(Process::CLOCK_THREAD_CPUTIME_ID) : 0 - end end end end diff --git a/lib/gitlab/sidekiq_middleware/server_metrics.rb b/lib/gitlab/sidekiq_middleware/server_metrics.rb new file mode 100644 index 0000000000000000000000000000000000000000..fa7f56b8d9cb04c0625a0ebd7be6d421725f4394 --- /dev/null +++ b/lib/gitlab/sidekiq_middleware/server_metrics.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +module Gitlab + module SidekiqMiddleware + class ServerMetrics < SidekiqMiddleware::Metrics + # SIDEKIQ_LATENCY_BUCKETS are latency histogram buckets better suited to Sidekiq + # timeframes than the DEFAULT_BUCKET definition. Defined in seconds. + SIDEKIQ_LATENCY_BUCKETS = [0.1, 0.25, 0.5, 1, 2.5, 5, 10, 60, 300, 600].freeze + + def initialize + @metrics = init_metrics + + @metrics[:sidekiq_concurrency].set({}, Sidekiq.options[:concurrency].to_i) + end + + def call(worker, job, queue) + labels = create_labels(worker.class, queue) + queue_duration = ::Gitlab::InstrumentationHelper.queue_duration_for_job(job) + + @metrics[:sidekiq_jobs_queue_duration_seconds].observe(labels, queue_duration) if queue_duration + @metrics[:sidekiq_running_jobs].increment(labels, 1) + + if job['retry_count'].present? + @metrics[:sidekiq_jobs_retried_total].increment(labels, 1) + end + + job_succeeded = false + monotonic_time_start = Gitlab::Metrics::System.monotonic_time + job_thread_cputime_start = get_thread_cputime + begin + yield + job_succeeded = true + ensure + monotonic_time_end = Gitlab::Metrics::System.monotonic_time + job_thread_cputime_end = get_thread_cputime + + monotonic_time = monotonic_time_end - monotonic_time_start + job_thread_cputime = job_thread_cputime_end - job_thread_cputime_start + + # sidekiq_running_jobs, sidekiq_jobs_failed_total should not include the job_status label + @metrics[:sidekiq_running_jobs].increment(labels, -1) + @metrics[:sidekiq_jobs_failed_total].increment(labels, 1) unless job_succeeded + + # job_status: done, fail match the job_status attribute in structured logging + labels[:job_status] = job_succeeded ? "done" : "fail" + @metrics[:sidekiq_jobs_cpu_seconds].observe(labels, job_thread_cputime) + @metrics[:sidekiq_jobs_completion_seconds].observe(labels, monotonic_time) + end + end + + private + + def init_metrics + { + sidekiq_jobs_cpu_seconds: ::Gitlab::Metrics.histogram(:sidekiq_jobs_cpu_seconds, 'Seconds of cpu time to run Sidekiq job', {}, SIDEKIQ_LATENCY_BUCKETS), + sidekiq_jobs_completion_seconds: ::Gitlab::Metrics.histogram(:sidekiq_jobs_completion_seconds, 'Seconds to complete Sidekiq job', {}, SIDEKIQ_LATENCY_BUCKETS), + sidekiq_jobs_queue_duration_seconds: ::Gitlab::Metrics.histogram(:sidekiq_jobs_queue_duration_seconds, 'Duration in seconds that a Sidekiq job was queued before being executed', {}, SIDEKIQ_LATENCY_BUCKETS), + sidekiq_jobs_failed_total: ::Gitlab::Metrics.counter(:sidekiq_jobs_failed_total, 'Sidekiq jobs failed'), + sidekiq_jobs_retried_total: ::Gitlab::Metrics.counter(:sidekiq_jobs_retried_total, 'Sidekiq jobs retried'), + sidekiq_running_jobs: ::Gitlab::Metrics.gauge(:sidekiq_running_jobs, 'Number of Sidekiq jobs running', {}, :all), + sidekiq_concurrency: ::Gitlab::Metrics.gauge(:sidekiq_concurrency, 'Maximum number of Sidekiq jobs', {}, :all) + } + end + + def get_thread_cputime + defined?(Process::CLOCK_THREAD_CPUTIME_ID) ? Process.clock_gettime(Process::CLOCK_THREAD_CPUTIME_ID) : 0 + end + end + end +end diff --git a/lib/gitlab/usage_data.rb b/lib/gitlab/usage_data.rb index ec2243345e100756d259ab76c1705a181b31454f..e00b49b9042b3b357a540003adf23c95ff2f7327 100644 --- a/lib/gitlab/usage_data.rb +++ b/lib/gitlab/usage_data.rb @@ -178,18 +178,17 @@ module Gitlab # rubocop: disable CodeReuse/ActiveRecord def services_usage - types = { - SlackService: :projects_slack_notifications_active, - SlackSlashCommandsService: :projects_slack_slash_active, - PrometheusService: :projects_prometheus_active, - CustomIssueTrackerService: :projects_custom_issue_tracker_active, - JenkinsService: :projects_jenkins_active, - MattermostService: :projects_mattermost_active - } + service_counts = count(Service.active.where(template: false).where.not(type: 'JiraService').group(:type), fallback: Hash.new(-1)) + + results = Service.available_services_names.each_with_object({}) do |service_name, response| + response["projects_#{service_name}_active".to_sym] = service_counts["#{service_name}_service".camelize] || 0 + end + + # Keep old Slack keys for backward compatibility, https://gitlab.com/gitlab-data/analytics/issues/3241 + results[:projects_slack_notifications_active] = results[:projects_slack_active] + results[:projects_slack_slash_active] = results[:projects_slack_slash_commands_active] - results = count(Service.active.by_type(types.keys).group(:type), fallback: Hash.new(-1)) - types.each_with_object({}) { |(klass, key), response| response[key] = results[klass.to_s] || 0 } - .merge(jira_usage) + results.merge(jira_usage) end def jira_usage @@ -206,7 +205,6 @@ module Gitlab .by_type(:JiraService) .includes(:jira_tracker_data) .find_in_batches(batch_size: BATCH_SIZE) do |services| - counts = services.group_by do |service| # TODO: Simplify as part of https://gitlab.com/gitlab-org/gitlab/issues/29404 service_url = service.data_fields&.url || (service.properties && service.properties['url']) @@ -224,17 +222,17 @@ module Gitlab results end + # rubocop: enable CodeReuse/ActiveRecord def user_preferences_usage {} # augmented in EE end - def count(relation, count_by: nil, fallback: -1) - count_by ? relation.count(count_by) : relation.count + def count(relation, fallback: -1) + relation.count rescue ActiveRecord::StatementInvalid fallback end - # rubocop: enable CodeReuse/ActiveRecord def approximate_counts approx_counts = Gitlab::Database::Count.approximate_counts(APPROXIMATE_COUNT_MODELS) diff --git a/lib/gitlab/utils.rb b/lib/gitlab/utils.rb index 7fbfc4c45c44c37586e052083291364e5ab6200d..7eddfc471f6a7888ff5ef5158424783c6ccb2671 100644 --- a/lib/gitlab/utils.rb +++ b/lib/gitlab/utils.rb @@ -50,6 +50,12 @@ module Gitlab .gsub(/(\A-+|-+\z)/, '') end + # Wraps ActiveSupport's Array#to_sentence to convert the given array to a + # comma-separated sentence joined with localized 'or' Strings instead of 'and'. + def to_exclusive_sentence(array) + array.to_sentence(two_words_connector: _(' or '), last_word_connector: _(', or ')) + end + # Converts newlines into HTML line break elements def nlbr(str) ActionView::Base.full_sanitizer.sanitize(+str, tags: []).gsub(/\r?\n/, '<br>').html_safe diff --git a/lib/gitlab/utils/lazy_attributes.rb b/lib/gitlab/utils/lazy_attributes.rb new file mode 100644 index 0000000000000000000000000000000000000000..79f3a7dcb532c3641d886ed48525e843cc30d2fe --- /dev/null +++ b/lib/gitlab/utils/lazy_attributes.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module Gitlab + module Utils + module LazyAttributes + extend ActiveSupport::Concern + include Gitlab::Utils::StrongMemoize + + class_methods do + def lazy_attr_reader(*one_or_more_names, type: nil) + names = Array.wrap(one_or_more_names) + names.each { |name| define_lazy_reader(name, type: type) } + end + + def lazy_attr_accessor(*one_or_more_names, type: nil) + names = Array.wrap(one_or_more_names) + names.each do |name| + define_lazy_reader(name, type: type) + define_lazy_writer(name) + end + end + + private + + def define_lazy_reader(name, type:) + define_method(name) do + strong_memoize("#{name}_lazy_loaded") do + value = instance_variable_get("@#{name}") + value = value.call if value.respond_to?(:call) + value = nil if type && !value.is_a?(type) + value + end + end + end + + def define_lazy_writer(name) + define_method("#{name}=") do |value| + clear_memoization("#{name}_lazy_loaded") + instance_variable_set("@#{name}", value) + end + end + end + end + end +end diff --git a/lib/gitlab/workhorse.rb b/lib/gitlab/workhorse.rb index 713ca31bbc554351977fbf00832be948917e39ef..29450a33289237fa1728ff18f3bd64cb30157212 100644 --- a/lib/gitlab/workhorse.rb +++ b/lib/gitlab/workhorse.rb @@ -22,18 +22,16 @@ module Gitlab def git_http_ok(repository, repo_type, user, action, show_all_refs: false) raise "Unsupported action: #{action}" unless ALLOWED_GIT_HTTP_ACTIONS.include?(action.to_s) - project = repository.project - attrs = { GL_ID: Gitlab::GlId.gl_id(user), - GL_REPOSITORY: repo_type.identifier_for_subject(project), + GL_REPOSITORY: repo_type.identifier_for_subject(repository.project), GL_USERNAME: user&.username, ShowAllRefs: show_all_refs, Repository: repository.gitaly_repository.to_h, GitConfigOptions: [], GitalyServer: { - address: Gitlab::GitalyClient.address(project.repository_storage), - token: Gitlab::GitalyClient.token(project.repository_storage), + address: Gitlab::GitalyClient.address(repository.storage), + token: Gitlab::GitalyClient.token(repository.storage), features: Feature::Gitaly.server_feature_flags } } diff --git a/lib/peek/views/active_record.rb b/lib/peek/views/active_record.rb index 1bb3ddb964aa27d78292e502398fa71be53ff35f..ed3470f81f4c774499a803d69110a95d02a46a1b 100644 --- a/lib/peek/views/active_record.rb +++ b/lib/peek/views/active_record.rb @@ -32,7 +32,7 @@ module Peek detail_store << { duration: finish - start, sql: data[:sql].strip, - backtrace: Gitlab::Profiler.clean_backtrace(caller) + backtrace: Gitlab::BacktraceCleaner.clean_backtrace(caller) } end end diff --git a/lib/peek/views/redis_detailed.rb b/lib/peek/views/redis_detailed.rb index 84041b6be730069d154881599be34f259c5732dc..14cabd62025a10ac557e1a71b99e06ec47adf781 100644 --- a/lib/peek/views/redis_detailed.rb +++ b/lib/peek/views/redis_detailed.rb @@ -23,7 +23,7 @@ module Gitlab detail_store << { cmd: args.first, duration: duration, - backtrace: ::Gitlab::Profiler.clean_backtrace(caller) + backtrace: ::Gitlab::BacktraceCleaner.clean_backtrace(caller) } end diff --git a/lib/prometheus/pid_provider.rb b/lib/prometheus/pid_provider.rb index 228639357ac09dc09d968bf4e66cbc4e9bb71265..32beeb0d31ebda1cd983efb4a10144afbe165e05 100644 --- a/lib/prometheus/pid_provider.rb +++ b/lib/prometheus/pid_provider.rb @@ -5,11 +5,11 @@ module Prometheus extend self def worker_id - if Sidekiq.server? + if Gitlab::Runtime.sidekiq? sidekiq_worker_id - elsif defined?(Unicorn::Worker) + elsif Gitlab::Runtime.unicorn? unicorn_worker_id - elsif defined?(::Puma) + elsif Gitlab::Runtime.puma? puma_worker_id else unknown_process_id diff --git a/lib/sentry/api_urls.rb b/lib/sentry/api_urls.rb new file mode 100644 index 0000000000000000000000000000000000000000..388d0531da18f13ad979cf5b3c510676d33c028f --- /dev/null +++ b/lib/sentry/api_urls.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module Sentry + class ApiUrls + def initialize(url_base) + @uri = URI(url_base).freeze + end + + def issues_url + with_path(File.join(@uri.path, '/issues/')) + end + + def issue_url(issue_id) + with_path("/api/0/issues/#{escape(issue_id)}/") + end + + def projects_url + with_path('/api/0/projects/') + end + + def issue_latest_event_url(issue_id) + with_path("/api/0/issues/#{escape(issue_id)}/events/latest/") + end + + private + + def with_path(new_path) + new_uri = @uri.dup + # Sentry API returns 404 if there are extra slashes in the URL + new_uri.path = new_path.squeeze('/') + + new_uri + end + + def escape(param) + CGI.escape(param.to_s) + end + end +end diff --git a/lib/sentry/client.rb b/lib/sentry/client.rb index 3df688a1fda553fcecaefdc578596ef7f17e4585..8898960c24d0fdd08e82aff5c75dff9606c86cf9 100644 --- a/lib/sentry/client.rb +++ b/lib/sentry/client.rb @@ -2,19 +2,15 @@ module Sentry class Client + include Sentry::Client::Event include Sentry::Client::Projects + include Sentry::Client::Issue + include Sentry::Client::Repo + include Sentry::Client::IssueLink Error = Class.new(StandardError) MissingKeysError = Class.new(StandardError) ResponseInvalidSizeError = Class.new(StandardError) - BadRequestError = Class.new(StandardError) - - SENTRY_API_SORT_VALUE_MAP = { - # <accepted_by_client> => <accepted_by_sentry_api> - 'frequency' => 'freq', - 'first_seen' => 'new', - 'last_seen' => nil - }.freeze attr_accessor :url, :token @@ -23,40 +19,10 @@ module Sentry @token = token end - def issue_details(issue_id:) - issue = get_issue(issue_id: issue_id) - - map_to_detailed_error(issue) - end - - def issue_latest_event(issue_id:) - latest_event = get_issue_latest_event(issue_id: issue_id) - - map_to_event(latest_event) - end - - def list_issues(**keyword_args) - response = get_issues(keyword_args) - - issues = response[:issues] - pagination = response[:pagination] - - validate_size(issues) - - handle_mapping_exceptions do - { - issues: map_to_errors(issues), - pagination: pagination - } - end - end - private - def validate_size(issues) - return if Gitlab::Utils::DeepSize.new(issues).valid? - - raise ResponseInvalidSizeError, "Sentry API response is too big. Limit is #{Gitlab::Utils::DeepSize.human_default_max_size}." + def api_urls + @api_urls ||= Sentry::ApiUrls.new(@url) end def handle_mapping_exceptions(&block) @@ -69,6 +35,7 @@ module Sentry def request_params { headers: { + 'Content-Type' => 'application/json', 'Authorization' => "Bearer #{@token}" }, follow_redirects: false @@ -76,43 +43,23 @@ module Sentry end def http_get(url, params = {}) - response = handle_request_exceptions do + http_request do Gitlab::HTTP.get(url, **request_params.merge(params)) end - handle_response(response) end - def get_issues(**keyword_args) - response = http_get( - issues_api_url, - query: list_issue_sentry_query(keyword_args) - ) - - { - issues: response[:body], - pagination: Sentry::PaginationParser.parse(response[:headers]) - } - end - - def list_issue_sentry_query(issue_status:, limit:, sort: nil, search_term: '', cursor: nil) - unless SENTRY_API_SORT_VALUE_MAP.key?(sort) - raise BadRequestError, 'Invalid value for sort param' + def http_put(url, params = {}) + http_request do + Gitlab::HTTP.put(url, **request_params.merge(body: params.to_json)) end - - { - query: "is:#{issue_status} #{search_term}".strip, - limit: limit, - sort: SENTRY_API_SORT_VALUE_MAP[sort], - cursor: cursor - }.compact end - def get_issue(issue_id:) - http_get(issue_api_url(issue_id))[:body] - end + def http_request + response = handle_request_exceptions do + yield + end - def get_issue_latest_event(issue_id:) - http_get(issue_latest_event_api_url(issue_id))[:body] + handle_response(response) end def handle_request_exceptions @@ -134,7 +81,7 @@ module Sentry end def handle_response(response) - unless response.code == 200 + unless response.code.between?(200, 204) raise_error "Sentry response status code: #{response.code}" end @@ -144,129 +91,5 @@ module Sentry def raise_error(message) raise Client::Error, message end - - def issue_api_url(issue_id) - issue_url = URI(@url) - issue_url.path = "/api/0/issues/#{issue_id}/" - - issue_url - end - - def issue_latest_event_api_url(issue_id) - latest_event_url = URI(@url) - latest_event_url.path = "/api/0/issues/#{issue_id}/events/latest/" - - latest_event_url - end - - def issues_api_url - issues_url = URI(@url + '/issues/') - issues_url.path.squeeze!('/') - - issues_url - end - - def map_to_errors(issues) - issues.map(&method(:map_to_error)) - end - - def issue_url(id) - issues_url = @url + "/issues/#{id}" - - parse_sentry_url(issues_url) - end - - def project_url - parse_sentry_url(@url) - end - - def parse_sentry_url(api_url) - url = ErrorTracking::ProjectErrorTrackingSetting.extract_sentry_external_url(api_url) - - uri = URI(url) - uri.path.squeeze!('/') - # Remove trailing slash - uri = uri.to_s.gsub(/\/\z/, '') - - uri - end - - def map_to_event(event) - stack_trace = parse_stack_trace(event) - - Gitlab::ErrorTracking::ErrorEvent.new( - issue_id: event.dig('groupID'), - date_received: event.dig('dateReceived'), - stack_trace_entries: stack_trace - ) - end - - def parse_stack_trace(event) - exception_entry = event.dig('entries')&.detect { |h| h['type'] == 'exception' } - return unless exception_entry - - exception_values = exception_entry.dig('data', 'values') - stack_trace_entry = exception_values&.detect { |h| h['stacktrace'].present? } - return unless stack_trace_entry - - stack_trace_entry.dig('stacktrace', 'frames') - end - - def parse_gitlab_issue(plugin_issues) - return unless plugin_issues - - gitlab_plugin = plugin_issues.detect { |item| item['id'] == 'gitlab' } - return unless gitlab_plugin - - gitlab_plugin.dig('issue', 'url') - end - - def map_to_detailed_error(issue) - Gitlab::ErrorTracking::DetailedError.new( - id: issue.fetch('id'), - first_seen: issue.fetch('firstSeen', nil), - last_seen: issue.fetch('lastSeen', nil), - title: issue.fetch('title', nil), - type: issue.fetch('type', nil), - user_count: issue.fetch('userCount', nil), - count: issue.fetch('count', nil), - message: issue.dig('metadata', 'value'), - culprit: issue.fetch('culprit', nil), - external_url: issue_url(issue.fetch('id')), - external_base_url: project_url, - short_id: issue.fetch('shortId', nil), - status: issue.fetch('status', nil), - frequency: issue.dig('stats', '24h'), - project_id: issue.dig('project', 'id'), - project_name: issue.dig('project', 'name'), - project_slug: issue.dig('project', 'slug'), - gitlab_issue: parse_gitlab_issue(issue.fetch('pluginIssues', nil)), - first_release_last_commit: issue.dig('firstRelease', 'lastCommit'), - last_release_last_commit: issue.dig('lastRelease', 'lastCommit'), - first_release_short_version: issue.dig('firstRelease', 'shortVersion'), - last_release_short_version: issue.dig('lastRelease', 'shortVersion') - ) - end - - def map_to_error(issue) - Gitlab::ErrorTracking::Error.new( - id: issue.fetch('id'), - first_seen: issue.fetch('firstSeen', nil), - last_seen: issue.fetch('lastSeen', nil), - title: issue.fetch('title', nil), - type: issue.fetch('type', nil), - user_count: issue.fetch('userCount', nil), - count: issue.fetch('count', nil), - message: issue.dig('metadata', 'value'), - culprit: issue.fetch('culprit', nil), - external_url: issue_url(issue.fetch('id')), - short_id: issue.fetch('shortId', nil), - status: issue.fetch('status', nil), - frequency: issue.dig('stats', '24h'), - project_id: issue.dig('project', 'id'), - project_name: issue.dig('project', 'name'), - project_slug: issue.dig('project', 'slug') - ) - end end end diff --git a/lib/sentry/client/event.rb b/lib/sentry/client/event.rb new file mode 100644 index 0000000000000000000000000000000000000000..01dfaa259697a868eeee2091a4eff69756b25441 --- /dev/null +++ b/lib/sentry/client/event.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module Sentry + class Client + module Event + def issue_latest_event(issue_id:) + latest_event = http_get(api_urls.issue_latest_event_url(issue_id))[:body] + + map_to_event(latest_event) + end + + private + + def map_to_event(event) + stack_trace = parse_stack_trace(event) + + Gitlab::ErrorTracking::ErrorEvent.new( + issue_id: event.dig('groupID'), + date_received: event.dig('dateReceived'), + stack_trace_entries: stack_trace + ) + end + + def parse_stack_trace(event) + exception_entry = event.dig('entries')&.detect { |h| h['type'] == 'exception' } + return [] unless exception_entry + + exception_values = exception_entry.dig('data', 'values') + stack_trace_entry = exception_values&.detect { |h| h['stacktrace'].present? } + return [] unless stack_trace_entry + + stack_trace_entry.dig('stacktrace', 'frames') || [] + end + end + end +end diff --git a/lib/sentry/client/issue.rb b/lib/sentry/client/issue.rb new file mode 100644 index 0000000000000000000000000000000000000000..1c5d88e886263e8ecb61edfb8c19206fa1d21939 --- /dev/null +++ b/lib/sentry/client/issue.rb @@ -0,0 +1,168 @@ +# frozen_string_literal: true + +module Sentry + class Client + module Issue + BadRequestError = Class.new(StandardError) + + SENTRY_API_SORT_VALUE_MAP = { + # <accepted_by_client> => <accepted_by_sentry_api> + 'frequency' => 'freq', + 'first_seen' => 'new', + 'last_seen' => nil + }.freeze + + def list_issues(**keyword_args) + response = get_issues(keyword_args) + + issues = response[:issues] + pagination = response[:pagination] + + validate_size(issues) + + handle_mapping_exceptions do + { + issues: map_to_errors(issues), + pagination: pagination + } + end + end + + def issue_details(issue_id:) + issue = get_issue(issue_id: issue_id) + + map_to_detailed_error(issue) + end + + def update_issue(issue_id:, params:) + http_put(api_urls.issue_url(issue_id), params)[:body] + end + + private + + def get_issues(**keyword_args) + response = http_get( + api_urls.issues_url, + query: list_issue_sentry_query(keyword_args) + ) + + { + issues: response[:body], + pagination: Sentry::PaginationParser.parse(response[:headers]) + } + end + + def list_issue_sentry_query(issue_status:, limit:, sort: nil, search_term: '', cursor: nil) + unless SENTRY_API_SORT_VALUE_MAP.key?(sort) + raise BadRequestError, 'Invalid value for sort param' + end + + { + query: "is:#{issue_status} #{search_term}".strip, + limit: limit, + sort: SENTRY_API_SORT_VALUE_MAP[sort], + cursor: cursor + }.compact + end + + def validate_size(issues) + return if Gitlab::Utils::DeepSize.new(issues).valid? + + raise ResponseInvalidSizeError, "Sentry API response is too big. Limit is #{Gitlab::Utils::DeepSize.human_default_max_size}." + end + + def get_issue(issue_id:) + http_get(api_urls.issue_url(issue_id))[:body] + end + + def parse_gitlab_issue(plugin_issues) + return unless plugin_issues + + gitlab_plugin = plugin_issues.detect { |item| item['id'] == 'gitlab' } + return unless gitlab_plugin + + gitlab_plugin.dig('issue', 'url') + end + + def issue_url(id) + parse_sentry_url("#{url}/issues/#{id}") + end + + def project_url + parse_sentry_url(url) + end + + def parse_sentry_url(api_url) + url = ErrorTracking::ProjectErrorTrackingSetting.extract_sentry_external_url(api_url) + + uri = URI(url) + uri.path.squeeze!('/') + # Remove trailing slash + uri = uri.to_s.gsub(/\/\z/, '') + + uri + end + + def map_to_errors(issues) + issues.map(&method(:map_to_error)) + end + + def map_to_error(issue) + Gitlab::ErrorTracking::Error.new( + id: issue.fetch('id'), + first_seen: issue.fetch('firstSeen', nil), + last_seen: issue.fetch('lastSeen', nil), + title: issue.fetch('title', nil), + type: issue.fetch('type', nil), + user_count: issue.fetch('userCount', nil), + count: issue.fetch('count', nil), + message: issue.dig('metadata', 'value'), + culprit: issue.fetch('culprit', nil), + external_url: issue_url(issue.fetch('id')), + short_id: issue.fetch('shortId', nil), + status: issue.fetch('status', nil), + frequency: issue.dig('stats', '24h'), + project_id: issue.dig('project', 'id'), + project_name: issue.dig('project', 'name'), + project_slug: issue.dig('project', 'slug') + ) + end + + def map_to_detailed_error(issue) + Gitlab::ErrorTracking::DetailedError.new({ + id: issue.fetch('id'), + first_seen: issue.fetch('firstSeen', nil), + last_seen: issue.fetch('lastSeen', nil), + tags: extract_tags(issue), + title: issue.fetch('title', nil), + type: issue.fetch('type', nil), + user_count: issue.fetch('userCount', nil), + count: issue.fetch('count', nil), + message: issue.dig('metadata', 'value'), + culprit: issue.fetch('culprit', nil), + external_url: issue_url(issue.fetch('id')), + external_base_url: project_url, + short_id: issue.fetch('shortId', nil), + status: issue.fetch('status', nil), + frequency: issue.dig('stats', '24h'), + gitlab_issue: parse_gitlab_issue(issue.fetch('pluginIssues', nil)), + project_id: issue.dig('project', 'id'), + project_name: issue.dig('project', 'name'), + project_slug: issue.dig('project', 'slug'), + first_release_last_commit: issue.dig('firstRelease', 'lastCommit'), + first_release_short_version: issue.dig('firstRelease', 'shortVersion'), + first_release_version: issue.dig('firstRelease', 'version'), + last_release_last_commit: issue.dig('lastRelease', 'lastCommit'), + last_release_short_version: issue.dig('lastRelease', 'shortVersion') + }) + end + + def extract_tags(issue) + { + level: issue.fetch('level', nil), + logger: issue.fetch('logger', nil) + } + end + end + end +end diff --git a/lib/sentry/client/issue_link.rb b/lib/sentry/client/issue_link.rb new file mode 100644 index 0000000000000000000000000000000000000000..200b1a6b435bdd89f5a67e37a49a2f140df005c9 --- /dev/null +++ b/lib/sentry/client/issue_link.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Sentry + class Client + module IssueLink + def create_issue_link(integration_id, sentry_issue_identifier, issue) + issue_link_url = issue_link_api_url(integration_id, sentry_issue_identifier) + + params = { + project: issue.project.id, + externalIssue: "#{issue.project.id}##{issue.iid}" + } + + http_put(issue_link_url, params) + end + + private + + def issue_link_api_url(integration_id, sentry_issue_identifier) + issue_link_url = URI(url) + issue_link_url.path = "/api/0/groups/#{sentry_issue_identifier}/integrations/#{integration_id}/" + + issue_link_url + end + end + end +end diff --git a/lib/sentry/client/projects.rb b/lib/sentry/client/projects.rb index 68f8fe0f9c9aa55696ee4e3217cca51804dde421..e686d4ff7150e7aed18c7b1e01bca46ac094c15a 100644 --- a/lib/sentry/client/projects.rb +++ b/lib/sentry/client/projects.rb @@ -14,14 +14,7 @@ module Sentry private def get_projects - http_get(projects_api_url)[:body] - end - - def projects_api_url - projects_url = URI(url) - projects_url.path = '/api/0/projects/' - - projects_url + http_get(api_urls.projects_url)[:body] end def map_to_projects(projects) diff --git a/lib/sentry/client/repo.rb b/lib/sentry/client/repo.rb new file mode 100644 index 0000000000000000000000000000000000000000..9a0ed3c734253c6d9c45e52c40fba8be1e03149a --- /dev/null +++ b/lib/sentry/client/repo.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Sentry + class Client + module Repo + def repos(organization_slug) + repos_url = repos_api_url(organization_slug) + + repos = http_get(repos_url)[:body] + + handle_mapping_exceptions do + map_to_repos(repos) + end + end + + private + + def repos_api_url(organization_slug) + repos_url = URI(url) + repos_url.path = "/api/0/organizations/#{organization_slug}/repos/" + + repos_url + end + + def map_to_repos(repos) + repos.map(&method(:map_to_repo)) + end + + def map_to_repo(repo) + Gitlab::ErrorTracking::Repo.new( + status: repo.fetch('status'), + integration_id: repo.fetch('integrationId'), + project_id: repo.fetch('externalSlug') + ) + end + end + end +end diff --git a/lib/tasks/plugins.rake b/lib/tasks/file_hooks.rake similarity index 52% rename from lib/tasks/plugins.rake rename to lib/tasks/file_hooks.rake index e73dd7e68dfa04ec357c2408b4fd6504b0b527bb..20a726de65b0a9b8d4223af6ba0f79b3bffdb717 100644 --- a/lib/tasks/plugins.rake +++ b/lib/tasks/file_hooks.rake @@ -1,10 +1,10 @@ -namespace :plugins do +namespace :file_hooks do desc 'Validate existing plugins' task validate: :environment do - puts 'Validating plugins from /plugins directory' + puts 'Validating file hooks from /plugins directory' - Gitlab::Plugin.files.each do |file| - success, message = Gitlab::Plugin.execute(file, Gitlab::DataBuilder::Push::SAMPLE_DATA) + Gitlab::FileHook.files.each do |file| + success, message = Gitlab::FileHook.execute(file, Gitlab::DataBuilder::Push::SAMPLE_DATA) if success puts "* #{file} succeed (zero exit code)." diff --git a/lib/tasks/gitlab/generate_sample_prometheus_data.rake b/lib/tasks/gitlab/generate_sample_prometheus_data.rake index a988494ca6145363085e0c4bd371ae4d946afed8..250eaaa556827e3121315ffe8fc91c51c878a66d 100644 --- a/lib/tasks/gitlab/generate_sample_prometheus_data.rake +++ b/lib/tasks/gitlab/generate_sample_prometheus_data.rake @@ -8,12 +8,17 @@ namespace :gitlab do sample_metrics_directory_name = Metrics::SampleMetricsService::DIRECTORY FileUtils.mkdir_p(sample_metrics_directory_name) + sample_metrics_intervals = [30.minutes, 180.minutes, 8.hours, 24.hours, 72.hours, 7.days] + metrics.each do |metric| query = metric.query % query_variables - result = environment.prometheus_adapter.prometheus_client.query_range(query, start: 7.days.ago) next unless metric.identifier + result = sample_metrics_intervals.each_with_object({}) do |interval, memo| + memo[interval.to_i / 60] = environment.prometheus_adapter.prometheus_client.query_range(query, start: interval.ago) + end + File.write("#{sample_metrics_directory_name}/#{metric.identifier}.yml", result.to_yaml) end end diff --git a/lib/tasks/gitlab/lfs/migrate.rake b/lib/tasks/gitlab/lfs/migrate.rake index 4142903d9c39829eb245be04f16de19f56f1a825..6f11646c841f14792ba5a0eb5948cfcf017f3b41 100644 --- a/lib/tasks/gitlab/lfs/migrate.rake +++ b/lib/tasks/gitlab/lfs/migrate.rake @@ -9,7 +9,6 @@ namespace :gitlab do LfsObject.with_files_stored_locally .find_each(batch_size: 10) do |lfs_object| - lfs_object.file.migrate!(LfsObjectUploader::Store::REMOTE) logger.info("Transferred LFS object #{lfs_object.oid} of size #{lfs_object.size.to_i.bytes} to object storage") @@ -24,7 +23,6 @@ namespace :gitlab do LfsObject.with_files_stored_remotely .find_each(batch_size: 10) do |lfs_object| - lfs_object.file.migrate!(LfsObjectUploader::Store::LOCAL) logger.info("Transferred LFS object #{lfs_object.oid} of size #{lfs_object.size.to_i.bytes} to local storage") diff --git a/lib/tasks/pngquant.rake b/lib/tasks/pngquant.rake new file mode 100644 index 0000000000000000000000000000000000000000..56dfd5ed081d458804df4f6f6300055cce7fbd86 --- /dev/null +++ b/lib/tasks/pngquant.rake @@ -0,0 +1,97 @@ +return if Rails.env.production? + +require 'png_quantizator' +require 'parallel' + +# The amount of variance (in bytes) allowed in +# file size when testing for compression size +TOLERANCE = 10000 + +namespace :pngquant do + # Returns an array of all images eligible for compression + def doc_images + Dir.glob('doc/**/*.png', File::FNM_CASEFOLD) + end + + # Runs pngquant on an image and optionally + # writes the result to disk + def compress_image(file, overwrite_original) + compressed_file = "#{file}.compressed" + FileUtils.copy(file, compressed_file) + + pngquant_file = PngQuantizator::Image.new(compressed_file) + + # Run the image repeatedly through pngquant until + # the change in file size is within TOLERANCE + loop do + before = File.size(compressed_file) + pngquant_file.quantize! + after = File.size(compressed_file) + break if before - after <= TOLERANCE + end + + savings = File.size(file) - File.size(compressed_file) + is_uncompressed = savings > TOLERANCE + + if is_uncompressed && overwrite_original + FileUtils.copy(compressed_file, file) + end + + FileUtils.remove(compressed_file) + + [is_uncompressed, savings] + end + + # Ensures pngquant is available and prints an error if not + def check_executable + unless system('pngquant --version', out: File::NULL) + warn( + 'Error: pngquant executable was not detected in the system.'.color(:red), + 'Download pngquant at https://pngquant.org/ and place the executable in /usr/local/bin'.color(:green) + ) + abort + end + end + + desc 'GitLab | pngquant | Compress all documentation PNG images using pngquant' + task :compress do + check_executable + + files = doc_images + puts "Compressing #{files.size} PNG files in doc/**" + + Parallel.each(files) do |file| + was_uncompressed, savings = compress_image(file, true) + + if was_uncompressed + puts "#{file} was reduced by #{savings} bytes" + end + end + end + + desc 'GitLab | pngquant | Checks that all documentation PNG images have been compressed with pngquant' + task :lint do + check_executable + + files = doc_images + puts "Checking #{files.size} PNG files in doc/**" + + uncompressed_files = Parallel.map(files) do |file| + is_uncompressed, _ = compress_image(file, false) + if is_uncompressed + puts "Uncompressed file detected: ".color(:red) + file + file + end + end.compact + + if uncompressed_files.empty? + puts "All documentation images are optimally compressed!".color(:green) + else + warn( + "The #{uncompressed_files.size} image(s) above have not been optimally compressed using pngquant.".color(:red), + 'Please run "bin/rake pngquant:compress" and commit the result.' + ) + abort + end + end +end diff --git a/lib/tasks/sidekiq.rake b/lib/tasks/sidekiq.rake index dd9ce86f7cab00f3d696377b42ddb75d672ccdc6..cb9f4c751ed0d83e983397670b41dc3bef5b5aba 100644 --- a/lib/tasks/sidekiq.rake +++ b/lib/tasks/sidekiq.rake @@ -1,21 +1,38 @@ namespace :sidekiq do - desc "GitLab | Stop sidekiq" + def deprecation_warning! + warn <<~WARNING + This task is deprecated and will be removed in 13.0 as it is thought to be unused. + + If you are using this task, please comment on the below issue: + https://gitlab.com/gitlab-org/gitlab/issues/196731 + WARNING + end + + desc "[DEPRECATED] GitLab | Stop sidekiq" task :stop do + deprecation_warning! + system(*%w(bin/background_jobs stop)) end - desc "GitLab | Start sidekiq" + desc "[DEPRECATED] GitLab | Start sidekiq" task :start do + deprecation_warning! + system(*%w(bin/background_jobs start)) end - desc 'GitLab | Restart sidekiq' + desc '[DEPRECATED] GitLab | Restart sidekiq' task :restart do + deprecation_warning! + system(*%w(bin/background_jobs restart)) end - desc "GitLab | Start sidekiq with launchd on Mac OS X" + desc "[DEPRECATED] GitLab | Start sidekiq with launchd on Mac OS X" task :launchd do + deprecation_warning! + system(*%w(bin/background_jobs start_no_deamonize)) end end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 40d5e7223d6c3834ee56ceb9d231eedc7c0c2acb..04ce92d64eca047a6d9477d6cfc9ac3e1b687674 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -269,7 +269,7 @@ msgstr "" msgid "%{firstLabel} +%{labelCount} more" msgstr "" -msgid "%{from} to %{to}" +msgid "%{global_id} is not a valid id for %{expected_type}." msgstr "" msgid "%{group_docs_link_start}Groups%{group_docs_link_end} allow you to manage and collaborate across multiple projects. Members of a group have access to all of its projects." @@ -284,6 +284,12 @@ msgstr "" msgid "%{issuableType} will be removed! Are you sure?" msgstr "" +msgid "%{issuesSize} issues" +msgstr "" + +msgid "%{issuesSize} issues with a limit of %{maxIssueCount}" +msgstr "" + msgid "%{label_for_message} unavailable" msgstr "" @@ -332,6 +338,12 @@ msgstr "" msgid "%{openOrClose} %{noteable}" msgstr "" +msgid "%{openedEpics} open, %{closedEpics} closed" +msgstr "" + +msgid "%{openedIssues} open, %{closedIssues} closed" +msgstr "" + msgid "%{percent}%% complete" msgstr "" @@ -367,6 +379,18 @@ msgstr "" msgid "%{spammable_titlecase} was submitted to Akismet successfully." msgstr "" +msgid "%{spanStart}at line%{spanEnd} %{errorLine}%{errorColumn}" +msgstr "" + +msgid "%{spanStart}in%{spanEnd} %{errorFn}" +msgstr "" + +msgid "%{staged} staged and %{unstaged} unstaged changes" +msgstr "" + +msgid "%{start} to %{end}" +msgstr "" + msgid "%{state} epics" msgstr "" @@ -383,6 +407,11 @@ msgstr[1] "" msgid "%{strong_start}%{human_size}%{strong_end} Files" msgstr "" +msgid "%{strong_start}%{release_count}%{strong_end} Release" +msgid_plural "%{strong_start}%{release_count}%{strong_end} Releases" +msgstr[0] "" +msgstr[1] "" + msgid "%{strong_start}%{tag_count}%{strong_end} Tag" msgid_plural "%{strong_start}%{tag_count}%{strong_end} Tags" msgstr[0] "" @@ -417,6 +446,9 @@ msgstr "" msgid "%{title} changes" msgstr "" +msgid "%{totalWeight} total weight" +msgstr "" + msgid "%{total} open issue weight" msgstr "" @@ -545,18 +577,30 @@ msgstr[0] "" msgstr[1] "" msgid "1 day" -msgstr "" +msgid_plural "%d days" +msgstr[0] "" +msgstr[1] "" msgid "1 group" msgid_plural "%d groups" msgstr[0] "" msgstr[1] "" +msgid "1 hour" +msgid_plural "%d hours" +msgstr[0] "" +msgstr[1] "" + msgid "1 merged merge request" msgid_plural "%{merge_requests} merged merge requests" msgstr[0] "" msgstr[1] "" +msgid "1 minute" +msgid_plural "%d minutes" +msgstr[0] "" +msgstr[1] "" + msgid "1 open issue" msgid_plural "%{issues} open issues" msgstr[0] "" @@ -597,6 +641,9 @@ msgstr "" msgid "20-29 contributions" msgstr "" +msgid "24 hours" +msgstr "" + msgid "2FA" msgstr "" @@ -609,6 +656,9 @@ msgstr "" msgid "3 hours" msgstr "" +msgid "30 days" +msgstr "" + msgid "30 minutes" msgstr "" @@ -630,9 +680,15 @@ msgstr "" msgid "404|Please contact your GitLab administrator if you think this is a mistake." msgstr "" +msgid "7 days" +msgstr "" + msgid "8 hours" msgstr "" +msgid "< 1 hour" +msgstr "" + msgid "<code>\"johnsmith@example.com\": \"@johnsmith\"</code> will add \"By <a href=\"#\">@johnsmith</a>\" to all issues and comments originally created by johnsmith@example.com, and will set <a href=\"#\">@johnsmith</a> as the assignee on all issues originally assigned to johnsmith@example.com." msgstr "" @@ -654,16 +710,10 @@ msgstr "" msgid "<strong>%{changedFilesLength} unstaged</strong> and <strong>%{stagedFilesLength} staged</strong> changes" msgstr "" -msgid "<strong>%{created_count}</strong> created, <strong>%{accepted_count}</strong> accepted." -msgstr "" - -msgid "<strong>%{created_count}</strong> created, <strong>%{closed_count}</strong> closed." -msgstr "" - msgid "<strong>%{group_name}</strong> group members" msgstr "" -msgid "<strong>%{pushes}</strong> pushes, more than <strong>%{commits}</strong> commits by <strong>%{people}</strong> contributors." +msgid "<strong>%{stagedFilesLength} staged</strong> and <strong>%{changedFilesLength} unstaged</strong> changes" msgstr "" msgid "<strong>Deletes</strong> source branch" @@ -702,6 +752,9 @@ msgstr "" msgid "A deleted user" msgstr "" +msgid "A file with '%{file_name}' already exists in %{branch} branch" +msgstr "" + msgid "A fork is a copy of a project.<br />Forking a repository allows you to make changes without affecting the original project." msgstr "" @@ -819,6 +872,9 @@ msgstr "" msgid "Access to '%{classification_label}' not allowed" msgstr "" +msgid "Access to Pages websites are controlled based on the user's membership to a given project. By checking this box, users will be required to be logged in to have access to all Pages websites in your instance." +msgstr "" + msgid "AccessDropdown|Groups" msgstr "" @@ -1046,9 +1102,6 @@ msgstr "" msgid "Add new directory" msgstr "" -msgid "Add new member to %{strong_start}%{group_name}%{strong_end}" -msgstr "" - msgid "Add or subtract spent time" msgstr "" @@ -1073,9 +1126,6 @@ msgstr "" msgid "Add to merge train when pipeline succeeds" msgstr "" -msgid "Add to project" -msgstr "" - msgid "Add to review" msgstr "" @@ -1328,6 +1378,9 @@ msgstr "" msgid "AdminUsers|External" msgstr "" +msgid "AdminUsers|Is using seat" +msgstr "" + msgid "AdminUsers|It's you!" msgstr "" @@ -1621,6 +1674,9 @@ msgstr "" msgid "An error occurred while deleting the comment" msgstr "" +msgid "An error occurred while deleting the pipeline." +msgstr "" + msgid "An error occurred while detecting host keys" msgstr "" @@ -1717,9 +1773,15 @@ msgstr "" msgid "An error occurred while loading filenames" msgstr "" +msgid "An error occurred while loading group members." +msgstr "" + msgid "An error occurred while loading issues" msgstr "" +msgid "An error occurred while loading merge requests." +msgstr "" + msgid "An error occurred while loading the data. Please try again." msgstr "" @@ -2022,6 +2084,9 @@ msgstr "" msgid "Approved the current merge request." msgstr "" +msgid "Approver" +msgstr "" + msgid "Apr" msgstr "" @@ -2079,6 +2144,9 @@ msgstr "" msgid "Are you sure you want to delete this pipeline schedule?" msgstr "" +msgid "Are you sure you want to delete this pipeline? Doing so will expire all pipeline caches and delete all related objects, such as builds, logs, artifacts, and triggers. This action cannot be undone." +msgstr "" + msgid "Are you sure you want to erase this build?" msgstr "" @@ -2139,6 +2207,9 @@ msgstr "" msgid "Are you sure? Removing this GPG key does not affect already signed commits." msgstr "" +msgid "Are you sure? The device will be signed out of GitLab." +msgstr "" + msgid "Are you sure? This will invalidate your registered applications and U2F devices." msgstr "" @@ -2365,9 +2436,15 @@ msgstr "" msgid "Auto-cancel redundant, pending pipelines" msgstr "" +msgid "Auto-close referenced issues on default branch" +msgstr "" + msgid "AutoDevOps|Auto DevOps" msgstr "" +msgid "AutoDevOps|Auto DevOps can automatically build, test, and deploy applications based on predefined continuous integration and delivery configuration. %{auto_devops_start}Learn more about Auto DevOps%{auto_devops_end} or use our %{quickstart_start}quick start guide%{quickstart_end} to get started right away." +msgstr "" + msgid "AutoDevOps|Auto DevOps documentation" msgstr "" @@ -2551,6 +2628,9 @@ msgstr "" msgid "BambooService|You must set up automatic revision labeling and a repository trigger in Bamboo." msgstr "" +msgid "Batch operations" +msgstr "" + msgid "BatchComments|Delete all pending comments" msgstr "" @@ -2644,6 +2724,9 @@ msgstr "" msgid "Blocked" msgstr "" +msgid "Blocks" +msgstr "" + msgid "Blog" msgstr "" @@ -2938,9 +3021,6 @@ msgstr "" msgid "CICD|Auto DevOps" msgstr "" -msgid "CICD|Auto DevOps will automatically build, test, and deploy your application based on a predefined Continuous Integration and Delivery configuration." -msgstr "" - msgid "CICD|Automatic deployment to staging, manual deployment to production" msgstr "" @@ -2962,9 +3042,6 @@ msgstr "" msgid "CICD|Jobs" msgstr "" -msgid "CICD|Learn more about Auto DevOps" -msgstr "" - msgid "CICD|The Auto DevOps pipeline will run if no alternative CI configuration file is found." msgstr "" @@ -3244,6 +3321,21 @@ msgstr "" msgid "Checkout" msgstr "" +msgid "Checkout|$%{selectedPlanPrice} per user per year" +msgstr "" + +msgid "Checkout|%{name}'s GitLab subscription" +msgstr "" + +msgid "Checkout|%{selectedPlanText} plan" +msgstr "" + +msgid "Checkout|%{startDate} - %{endDate}" +msgstr "" + +msgid "Checkout|(x%{numberOfUsers})" +msgstr "" + msgid "Checkout|1. Your profile" msgstr "" @@ -3256,12 +3348,57 @@ msgstr "" msgid "Checkout|Checkout" msgstr "" +msgid "Checkout|Continue to billing" +msgstr "" + +msgid "Checkout|Edit" +msgstr "" + +msgid "Checkout|GitLab plan" +msgstr "" + +msgid "Checkout|Group" +msgstr "" + +msgid "Checkout|Name of company or organization using GitLab" +msgstr "" + +msgid "Checkout|Need more users? Purchase GitLab for your %{company}." +msgstr "" + +msgid "Checkout|Number of users" +msgstr "" + +msgid "Checkout|Subscription details" +msgstr "" + +msgid "Checkout|Subtotal" +msgstr "" + +msgid "Checkout|Tax" +msgstr "" + +msgid "Checkout|Total" +msgstr "" + +msgid "Checkout|Users" +msgstr "" + +msgid "Checkout|Your organization" +msgstr "" + +msgid "Checkout|company or team" +msgstr "" + msgid "Cherry-pick this commit" msgstr "" msgid "Cherry-pick this merge request" msgstr "" +msgid "Child" +msgstr "" + msgid "Child epic does not exist." msgstr "" @@ -3355,6 +3492,9 @@ msgstr "" msgid "CiStatusLabel|waiting for manual action" msgstr "" +msgid "CiStatusLabel|waiting for resource" +msgstr "" + msgid "CiStatusText|blocked" msgstr "" @@ -3385,6 +3525,9 @@ msgstr "" msgid "CiStatusText|skipped" msgstr "" +msgid "CiStatusText|waiting" +msgstr "" + msgid "CiStatus|running" msgstr "" @@ -3748,9 +3891,6 @@ msgstr "" msgid "ClusterIntegration|Copy Jupyter Hostname" msgstr "" -msgid "ClusterIntegration|Copy Kibana Hostname" -msgstr "" - msgid "ClusterIntegration|Copy Knative Endpoint" msgstr "" @@ -3772,6 +3912,9 @@ msgstr "" msgid "ClusterIntegration|Could not load instance types" msgstr "" +msgid "ClusterIntegration|Could not load networks" +msgstr "" + msgid "ClusterIntegration|Could not load regions from your AWS account" msgstr "" @@ -3781,6 +3924,9 @@ msgstr "" msgid "ClusterIntegration|Could not load subnets for the selected VPC" msgstr "" +msgid "ClusterIntegration|Could not load subnetworks" +msgstr "" + msgid "ClusterIntegration|Create Kubernetes cluster" msgstr "" @@ -3823,6 +3969,9 @@ msgstr "" msgid "ClusterIntegration|Enable Cloud Run on GKE (beta)" msgstr "" +msgid "ClusterIntegration|Enable Web Application Firewall" +msgstr "" + msgid "ClusterIntegration|Enable or disable GitLab's connection to your Kubernetes cluster." msgstr "" @@ -3958,9 +4107,6 @@ msgstr "" msgid "ClusterIntegration|Key pair name" msgstr "" -msgid "ClusterIntegration|Kibana Hostname" -msgstr "" - msgid "ClusterIntegration|Knative" msgstr "" @@ -4009,6 +4155,9 @@ msgstr "" msgid "ClusterIntegration|Learn more about %{help_link_start}zones%{help_link_end}." msgstr "" +msgid "ClusterIntegration|Learn more about %{startLink}ModSecurity%{endLink}" +msgstr "" + msgid "ClusterIntegration|Learn more about %{startLink}Regions %{externalLinkIcon}%{endLink}." msgstr "" @@ -4039,12 +4188,18 @@ msgstr "" msgid "ClusterIntegration|Loading instance types" msgstr "" +msgid "ClusterIntegration|Loading networks" +msgstr "" + msgid "ClusterIntegration|Loading security groups" msgstr "" msgid "ClusterIntegration|Loading subnets" msgstr "" +msgid "ClusterIntegration|Loading subnetworks" +msgstr "" + msgid "ClusterIntegration|Machine type" msgstr "" @@ -4069,6 +4224,9 @@ msgstr "" msgid "ClusterIntegration|No machine types matched your search" msgstr "" +msgid "ClusterIntegration|No networks found" +msgstr "" + msgid "ClusterIntegration|No projects found" msgstr "" @@ -4084,6 +4242,9 @@ msgstr "" msgid "ClusterIntegration|No subnet found" msgstr "" +msgid "ClusterIntegration|No subnetworks found" +msgstr "" + msgid "ClusterIntegration|No zones matched your search" msgstr "" @@ -4159,9 +4320,6 @@ msgstr "" msgid "ClusterIntegration|Request to begin uninstalling failed" msgstr "" -msgid "ClusterIntegration|Role name" -msgstr "" - msgid "ClusterIntegration|Save changes" msgstr "" @@ -4180,6 +4338,9 @@ msgstr "" msgid "ClusterIntegration|Search machine types" msgstr "" +msgid "ClusterIntegration|Search networks" +msgstr "" + msgid "ClusterIntegration|Search projects" msgstr "" @@ -4192,6 +4353,9 @@ msgstr "" msgid "ClusterIntegration|Search subnets" msgstr "" +msgid "ClusterIntegration|Search subnetworks" +msgstr "" + msgid "ClusterIntegration|Search zones" msgstr "" @@ -4210,6 +4374,9 @@ msgstr "" msgid "ClusterIntegration|Select a VPC to use for your EKS Cluster resources. To use a new VPC, first create one on %{startLink}Amazon Web Services %{externalLinkIcon} %{endLink}." msgstr "" +msgid "ClusterIntegration|Select a network to choose a subnetwork" +msgstr "" + msgid "ClusterIntegration|Select a region to choose a Key Pair" msgstr "" @@ -4219,6 +4386,9 @@ msgstr "" msgid "ClusterIntegration|Select a stack to install Crossplane." msgstr "" +msgid "ClusterIntegration|Select a zone to choose a network" +msgstr "" + msgid "ClusterIntegration|Select machine type" msgstr "" @@ -4231,9 +4401,6 @@ msgstr "" msgid "ClusterIntegration|Select project to choose zone" msgstr "" -msgid "ClusterIntegration|Select the IAM Role to allow Amazon EKS and the Kubernetes control plane to manage AWS resources on your behalf. To use a new role name, first create one on %{startLink}Amazon Web Services %{externalLinkIcon} %{endLink}." -msgstr "" - msgid "ClusterIntegration|Select the key pair name that will be used to create EC2 nodes. To use a new key pair name, first create one on %{startLink}Amazon Web Services %{externalLinkIcon} %{endLink}." msgstr "" @@ -4246,6 +4413,9 @@ msgstr "" msgid "ClusterIntegration|Service Token" msgstr "" +msgid "ClusterIntegration|Service role" +msgstr "" + msgid "ClusterIntegration|Service token is required." msgstr "" @@ -4372,6 +4542,9 @@ msgstr "" msgid "ClusterIntegration|Your cluster API is unreachable. Please ensure your API URL is correct." msgstr "" +msgid "ClusterIntegration|Your service role is distinct from the provision role used when authenticating. It will allow Amazon EKS and the Kubernetes control plane to manage AWS resources on your behalf. To use a new role, first create one on %{startLink}Amazon Web Services %{externalLinkIcon} %{endLink}." +msgstr "" + msgid "ClusterIntegration|Zone" msgstr "" @@ -4396,6 +4569,9 @@ msgstr "" msgid "ClusterIntergation|Select a VPC" msgstr "" +msgid "ClusterIntergation|Select a network" +msgstr "" + msgid "ClusterIntergation|Select a region" msgstr "" @@ -4405,34 +4581,34 @@ msgstr "" msgid "ClusterIntergation|Select a subnet" msgstr "" +msgid "ClusterIntergation|Select a subnetwork" +msgstr "" + msgid "ClusterIntergation|Select an instance type" msgstr "" msgid "ClusterIntergation|Select key pair" msgstr "" -msgid "ClusterIntergation|Select role name" +msgid "ClusterIntergation|Select service role" msgstr "" msgid "Code" msgstr "" -msgid "Code Analytics" -msgstr "" - msgid "Code Owners" msgstr "" msgid "Code Owners to the merge request changes." msgstr "" -msgid "Code owner approval is required" +msgid "Code Review" msgstr "" -msgid "Code owners" +msgid "Code owner approval is required" msgstr "" -msgid "CodeAnalytics|Max files" +msgid "Code owners" msgstr "" msgid "CodeOwner|Pattern" @@ -4536,6 +4712,9 @@ msgstr "" msgid "Commit message" msgstr "" +msgid "Commit message (optional)" +msgstr "" + msgid "Commit statistics for %{ref} %{start_time} - %{end_time}" msgstr "" @@ -4746,7 +4925,7 @@ msgstr "" msgid "Container Registry" msgstr "" -msgid "Container Registry tag expiration policies" +msgid "Container Registry tag expiration policy" msgstr "" msgid "Container Scanning" @@ -4758,6 +4937,9 @@ msgstr "" msgid "Container registry is not enabled on this GitLab instance. Ask an administrator to enable it in order for AutoDevOps to work." msgstr "" +msgid "ContainerRegistry|Automatically remove extra images that aren't designed to be kept." +msgstr "" + msgid "ContainerRegistry|Container Registry" msgstr "" @@ -4773,15 +4955,39 @@ msgstr "" msgid "ContainerRegistry|Docker connection error" msgstr "" +msgid "ContainerRegistry|Docker tag expiration policy is %{toggleStatus}" +msgstr "" + +msgid "ContainerRegistry|Expiration interval:" +msgstr "" + +msgid "ContainerRegistry|Expiration policy successfully saved." +msgstr "" + +msgid "ContainerRegistry|Expiration policy:" +msgstr "" + +msgid "ContainerRegistry|Expiration schedule:" +msgstr "" + +msgid "ContainerRegistry|Expire Docker tags that match this regex:" +msgstr "" + msgid "ContainerRegistry|If you are not already logged in, you need to authenticate to the Container Registry by using your GitLab username and password. If you have %{twofaDocLinkStart}Two-Factor Authentication%{twofaDocLinkEnd} enabled, use a %{personalAccessTokensDocLinkStart}Personal Access Token%{personalAccessTokensDocLinkEnd} instead of a password." msgstr "" msgid "ContainerRegistry|Image ID" msgstr "" +msgid "ContainerRegistry|Keep and protect the images that matter most." +msgstr "" + msgid "ContainerRegistry|Last Updated" msgstr "" +msgid "ContainerRegistry|Number of tags to retain:" +msgstr "" + msgid "ContainerRegistry|Quick Start" msgstr "" @@ -4799,12 +5005,27 @@ msgstr[1] "" msgid "ContainerRegistry|Size" msgstr "" +msgid "ContainerRegistry|Something went wrong while fetching the expiration policy." +msgstr "" + +msgid "ContainerRegistry|Something went wrong while updating the expiration policy." +msgstr "" + msgid "ContainerRegistry|Tag" msgstr "" +msgid "ContainerRegistry|Tag expiration policy" +msgstr "" + +msgid "ContainerRegistry|Tag expiration policy is designed to:" +msgstr "" + msgid "ContainerRegistry|The last tag related to this image was recently removed. This empty image and any associated data will be automatically removed as part of the regular Garbage Collection process. If you have any questions, contact your administrator." msgstr "" +msgid "ContainerRegistry|The value of this input should be less than 255 characters" +msgstr "" + msgid "ContainerRegistry|There are no container images available in this group" msgstr "" @@ -4817,6 +5038,9 @@ msgstr "" msgid "ContainerRegistry|We are having trouble connecting to Docker, which could be due to an issue with your project name or path. %{docLinkStart}More Information%{docLinkEnd}" msgstr "" +msgid "ContainerRegistry|Wildcards such as %{codeStart}*-stable%{codeEnd} or %{codeStart}production/*%{codeEnd} are supported. To select all tags, use %{codeStart}.*%{codeEnd}" +msgstr "" + msgid "ContainerRegistry|With the Container Registry, every project can have its own space to store its Docker images. %{docLinkStart}More Information%{docLinkEnd}" msgstr "" @@ -4865,6 +5089,42 @@ msgstr "" msgid "Contribution Charts" msgstr "" +msgid "ContributionAnalytics|<strong>%{created_count}</strong> created, <strong>%{accepted_count}</strong> accepted." +msgstr "" + +msgid "ContributionAnalytics|<strong>%{created_count}</strong> created, <strong>%{closed_count}</strong> closed." +msgstr "" + +msgid "ContributionAnalytics|<strong>%{pushes}</strong> pushes, more than <strong>%{commits}</strong> commits by <strong>%{people}</strong> contributors." +msgstr "" + +msgid "ContributionAnalytics|Contribution analytics for issues, merge requests and push events since %{start_date}" +msgstr "" + +msgid "ContributionAnalytics|Issues" +msgstr "" + +msgid "ContributionAnalytics|Last 3 months" +msgstr "" + +msgid "ContributionAnalytics|Last month" +msgstr "" + +msgid "ContributionAnalytics|Last week" +msgstr "" + +msgid "ContributionAnalytics|Merge Requests" +msgstr "" + +msgid "ContributionAnalytics|No issues for the selected time period." +msgstr "" + +msgid "ContributionAnalytics|No merge requests for the selected time period." +msgstr "" + +msgid "ContributionAnalytics|No pushes for the selected time period." +msgstr "" + msgid "Contributions for <strong>%{calendar_date}</strong>" msgstr "" @@ -5000,12 +5260,18 @@ msgstr "" msgid "Could not create project" msgstr "" +msgid "Could not delete %{design}. Please try again." +msgstr "" + msgid "Could not delete chat nickname %{chat_name}." msgstr "" msgid "Could not fetch projects" msgstr "" +msgid "Could not find design" +msgstr "" + msgid "Could not remove the trigger." msgstr "" @@ -5018,6 +5284,9 @@ msgstr "" msgid "Could not revoke personal access token %{personal_access_token_name}." msgstr "" +msgid "Could not save group ID" +msgstr "" + msgid "Could not save project ID" msgstr "" @@ -5042,6 +5311,9 @@ msgstr "" msgid "Create New Domain" msgstr "" +msgid "Create Project" +msgstr "" + msgid "Create a GitLab account first, and then connect it to your %{label} account." msgstr "" @@ -5435,9 +5707,6 @@ msgstr "" msgid "CycleAnalyticsStage|Plan" msgstr "" -msgid "CycleAnalyticsStage|Production" -msgstr "" - msgid "CycleAnalyticsStage|Review" msgstr "" @@ -5447,6 +5716,9 @@ msgstr "" msgid "CycleAnalyticsStage|Test" msgstr "" +msgid "CycleAnalyticsStage|Total" +msgstr "" + msgid "CycleAnalyticsStage|is not available for the selected group" msgstr "" @@ -5473,12 +5745,30 @@ msgstr "" msgid "CycleAnalytics|No stages selected" msgstr "" +msgid "CycleAnalytics|Number of tasks" +msgstr "" + +msgid "CycleAnalytics|Showing %{subject} and %{selectedLabelsCount} labels" +msgstr "" + +msgid "CycleAnalytics|Showing data for group '%{groupName}' and %{selectedProjectCount} projects from %{startDate} to %{endDate}" +msgstr "" + +msgid "CycleAnalytics|Showing data for group '%{groupName}' from %{startDate} to %{endDate}" +msgstr "" + msgid "CycleAnalytics|Stages" msgstr "" +msgid "CycleAnalytics|Tasks by type" +msgstr "" + msgid "CycleAnalytics|Total days to completion" msgstr "" +msgid "CycleAnalytics|Type of work" +msgstr "" + msgid "CycleAnalytics|group dropdown filter" msgstr "" @@ -5521,6 +5811,9 @@ msgstr "" msgid "Data is still calculating..." msgstr "" +msgid "Date" +msgstr "" + msgid "Date picker" msgstr "" @@ -5650,6 +5943,12 @@ msgstr "" msgid "Delete list" msgstr "" +msgid "Delete pipeline" +msgstr "" + +msgid "Delete project" +msgstr "" + msgid "Delete snippet" msgstr "" @@ -6014,16 +6313,19 @@ msgstr "" msgid "DesignManagement|Adding a design with the same filename replaces the file in a new version." msgstr "" +msgid "DesignManagement|Are you sure you want to cancel creating this comment?" +msgstr "" + msgid "DesignManagement|Are you sure you want to delete the selected designs?" msgstr "" -msgid "DesignManagement|Could not add a new comment. Please try again" +msgid "DesignManagement|Cancel comment confirmation" msgstr "" -msgid "DesignManagement|Could not create new discussion. Please try again." +msgid "DesignManagement|Could not add a new comment. Please try again." msgstr "" -msgid "DesignManagement|Could not find design, please try again." +msgid "DesignManagement|Could not create new discussion. Please try again." msgstr "" msgid "DesignManagement|Delete" @@ -6038,7 +6340,10 @@ msgstr "" msgid "DesignManagement|Deselect all" msgstr "" -msgid "DesignManagement|Error uploading a new design. Please try again" +msgid "DesignManagement|Discard comment" +msgstr "" + +msgid "DesignManagement|Error uploading a new design. Please try again." msgstr "" msgid "DesignManagement|Go back to designs" @@ -6050,7 +6355,7 @@ msgstr "" msgid "DesignManagement|Go to previous design" msgstr "" -msgid "DesignManagement|Requested design version does not exist" +msgid "DesignManagement|Keep comment" msgstr "" msgid "DesignManagement|Requested design version does not exist. Showing latest version instead" @@ -6068,9 +6373,6 @@ msgstr "" msgid "DesignManagement|Upload and view the latest designs for this issue. Consistent and easy to find, so everyone is up to date." msgstr "" -msgid "DesignManagement|We could not delete %{design}. Please try again." -msgstr "" - msgid "Designs" msgstr "" @@ -6125,6 +6427,9 @@ msgstr "" msgid "Disable group Runners" msgstr "" +msgid "Disable public access to Pages sites" +msgstr "" + msgid "Disable shared Runners" msgstr "" @@ -6353,6 +6658,9 @@ msgstr "" msgid "Edit Deploy Key" msgstr "" +msgid "Edit Geo Node" +msgstr "" + msgid "Edit Group Hook" msgstr "" @@ -6701,9 +7009,6 @@ msgstr "" msgid "Enter zen mode" msgstr "" -msgid "EnviornmentDashboard|You are looking at the last updated environment" -msgstr "" - msgid "Environment" msgstr "" @@ -6722,6 +7027,9 @@ msgstr "" msgid "EnvironmentDashboard|Created through the Deployment API" msgstr "" +msgid "EnvironmentDashboard|You are looking at the last updated environment" +msgstr "" + msgid "Environments" msgstr "" @@ -6749,12 +7057,21 @@ msgstr "" msgid "EnvironmentsDashboard|More actions" msgstr "" +msgid "EnvironmentsDashboard|Read more." +msgstr "" + msgid "EnvironmentsDashboard|Remove" msgstr "" msgid "EnvironmentsDashboard|The environments dashboard provides a summary of each project's environments' status, including pipeline and alert statuses." msgstr "" +msgid "EnvironmentsDashboard|This dashboard displays a maximum of 7 projects and 3 environments per project. %{readMoreLink}" +msgstr "" + +msgid "Environments|An error occurred while canceling the auto stop, please try again" +msgstr "" + msgid "Environments|An error occurred while fetching the environments." msgstr "" @@ -6773,6 +7090,12 @@ msgstr "" msgid "Environments|Are you sure you want to stop this environment?" msgstr "" +msgid "Environments|Auto stop in" +msgstr "" + +msgid "Environments|Auto stops %{auto_stop_time}" +msgstr "" + msgid "Environments|Commit" msgstr "" @@ -6791,6 +7114,9 @@ msgstr "" msgid "Environments|Environments are places where code gets deployed, such as staging or production." msgstr "" +msgid "Environments|Install Elastic Stack on your cluster to enable advanced querying capabilities such as full text search." +msgstr "" + msgid "Environments|Job" msgstr "" @@ -6851,6 +7177,9 @@ msgstr "" msgid "Environments|Rollback environment %{name}?" msgstr "" +msgid "Environments|Search" +msgstr "" + msgid "Environments|Show all" msgstr "" @@ -6908,9 +7237,6 @@ msgstr "" msgid "Epics let you manage your portfolio of projects more efficiently and with less effort" msgstr "" -msgid "Epics|%{epicsCount} epics and %{issuesCount} issues" -msgstr "" - msgid "Epics|Add an epic" msgstr "" @@ -6986,16 +7312,13 @@ msgstr "" msgid "Error Tracking" msgstr "" -msgid "Error creating a new path" -msgstr "" - msgid "Error creating epic" msgstr "" msgid "Error deleting %{issuableType}" msgstr "" -msgid "Error details" +msgid "Error deleting project. Check logs for error details." msgstr "" msgid "Error fetching diverging counts for branches. Please try again." @@ -7094,6 +7417,9 @@ msgstr "" msgid "Error rendering markdown preview" msgstr "" +msgid "Error rendering query" +msgstr "" + msgid "Error saving label update." msgstr "" @@ -7127,9 +7453,6 @@ msgstr "" msgid "Error with Akismet. Please check the logs for more info." msgstr "" -msgid "Error:" -msgstr "" - msgid "ErrorTracking|Active" msgstr "" @@ -7259,10 +7582,10 @@ msgstr "" msgid "Excluding merge commits. Limited to 6,000 commits." msgstr "" -msgid "Existing" +msgid "Existing members and groups" msgstr "" -msgid "Existing members and groups" +msgid "Existing shares" msgstr "" msgid "Expand" @@ -7292,7 +7615,7 @@ msgstr "" msgid "Expiration date" msgstr "" -msgid "Expiration policies for the Container Registry are a perfect solution for keeping the Registry space down while still enjoying the full power of GitLab CI/CD." +msgid "Expiration policy for the Container Registry is a perfect solution for keeping the Registry space down while still enjoying the full power of GitLab CI/CD." msgstr "" msgid "Expired" @@ -7544,6 +7867,9 @@ msgstr "" msgid "Failed to update environment!" msgstr "" +msgid "Failed to update issue status" +msgstr "" + msgid "Failed to update issues, please try again." msgstr "" @@ -7739,10 +8065,11 @@ msgstr "" msgid "Fetching licenses failed. You are not permitted to perform this action." msgstr "" -msgid "File" -msgid_plural "Files" -msgstr[0] "" -msgstr[1] "" +msgid "File Hooks" +msgstr "" + +msgid "File Hooks (%{count})" +msgstr "" msgid "File added" msgstr "" @@ -7753,12 +8080,18 @@ msgstr "" msgid "File deleted" msgstr "" +msgid "File hooks are similar to system hooks but are executed as files instead of sending data to a URL." +msgstr "" + msgid "File mode changed from %{a_mode} to %{b_mode}" msgstr "" msgid "File moved" msgstr "" +msgid "File name" +msgstr "" + msgid "File templates" msgstr "" @@ -7792,6 +8125,9 @@ msgstr "" msgid "Filter by milestone name" msgstr "" +msgid "Filter by name..." +msgstr "" + msgid "Filter by two-factor authentication" msgstr "" @@ -7846,6 +8182,9 @@ msgstr "" msgid "Finished" msgstr "" +msgid "First Name is too long (maximum is %{max_length} characters)." +msgstr "" + msgid "First Seen" msgstr "" @@ -7915,6 +8254,9 @@ msgstr "" msgid "For more information, please review %{link_start_tag}Jaeger's configuration doc%{link_end_tag}" msgstr "" +msgid "For more information, see the File Hooks documentation." +msgstr "" + msgid "For more information, see the documentation on %{deactivating_usage_ping_link_start}deactivating the usage ping%{deactivating_usage_ping_link_end}." msgstr "" @@ -8056,6 +8398,9 @@ msgstr "" msgid "Geo Designs" msgstr "" +msgid "Geo Node Form" +msgstr "" + msgid "Geo Nodes" msgstr "" @@ -8149,6 +8494,9 @@ msgstr "" msgid "GeoNodes|Node Authentication was successfully repaired." msgstr "" +msgid "GeoNodes|Node URL" +msgstr "" + msgid "GeoNodes|Node was successfully removed." msgstr "" @@ -8158,13 +8506,13 @@ msgstr "" msgid "GeoNodes|Out of sync" msgstr "" -msgid "GeoNodes|Pausing replication stops the sync process." +msgid "GeoNodes|Pausing replication stops the sync process. Are you sure?" msgstr "" -msgid "GeoNodes|Removing a primary node stops the sync process for all nodes. Syncing cannot be resumed without losing some data on all secondaries. In this case we would recommend setting up all nodes from scratch. Are you sure?" +msgid "GeoNodes|Removing a Geo primary node stops the synchronization to all nodes. Are you sure?" msgstr "" -msgid "GeoNodes|Removing a secondary node stops the sync process. It is not currently possible to add back the same node without losing some data. We only recommend setting up a new secondary node in this case. Are you sure?" +msgid "GeoNodes|Removing a Geo secondary node stops the synchronization to that node. Are you sure?" msgstr "" msgid "GeoNodes|Replication slot WAL" @@ -8536,6 +8884,9 @@ msgstr "" msgid "GitLab allows you to continue using your license even if you exceed the number of seats you purchased. You will be required to pay for these seats when you renew your license." msgstr "" +msgid "GitLab commit" +msgstr "" + msgid "GitLab for Slack" msgstr "" @@ -8605,6 +8956,12 @@ msgstr "" msgid "GitLabPages|Learn how to upload your static site and have it served by GitLab by following the %{link_start}documentation on GitLab Pages%{link_end}." msgstr "" +msgid "GitLabPages|Learn more." +msgstr "" + +msgid "GitLabPages|Maximum size of pages (MB)" +msgstr "" + msgid "GitLabPages|New Domain" msgstr "" @@ -8629,12 +8986,18 @@ msgstr "" msgid "GitLabPages|Support for domains and certificates is disabled. Ask your system's administrator to enable it." msgstr "" +msgid "GitLabPages|The total size of deployed static content will be limited to this size. 0 for unlimited. Leave empty to inherit the global value." +msgstr "" + msgid "GitLabPages|Unverified" msgstr "" msgid "GitLabPages|Verified" msgstr "" +msgid "GitLabPages|When using Pages under the general domain of a GitLab instance (%{pages_host}), you cannot use HTTPS with sub-subdomains. This means that if your username/groupname contains a dot it will not work. This is a limitation of the HTTP Over TLS protocol. HTTP pages will continue to work provided you don't redirect HTTP to HTTPS." +msgstr "" + msgid "GitLabPages|With GitLab Pages you can host your static websites on GitLab. Combined with the power of GitLab CI and the help of GitLab Runner you can deploy static pages for your individual projects, your user or your group." msgstr "" @@ -8872,6 +9235,9 @@ msgstr "" msgid "Group ID: %{group_id}" msgstr "" +msgid "Group Owner must have signed in with SAML before enabling Group Managed Accounts" +msgstr "" + msgid "Group Runners" msgstr "" @@ -8905,6 +9271,9 @@ msgstr "" msgid "Group maintainers can register group runners in the %{link}" msgstr "" +msgid "Group members" +msgstr "" + msgid "Group name" msgstr "" @@ -8992,6 +9361,9 @@ msgstr "" msgid "GroupSAML|Generate a SCIM token to set up your System for Cross-Domain Identity Management." msgstr "" +msgid "GroupSAML|Identity" +msgstr "" + msgid "GroupSAML|Identity provider single sign on URL" msgstr "" @@ -9001,6 +9373,9 @@ msgstr "" msgid "GroupSAML|Manage your group’s membership while adding another level of security with SAML." msgstr "" +msgid "GroupSAML|Members" +msgstr "" + msgid "GroupSAML|Members will be forwarded here when signing in to your group. Get this from your identity provider, where it can also be called \"SSO Service Location\", \"SAML Token Issuance Endpoint\", or \"SAML 2.0/W-Federation URL\"." msgstr "" @@ -9064,9 +9439,6 @@ msgstr "" msgid "GroupSettings|Auto DevOps pipeline was updated for the group" msgstr "" -msgid "GroupSettings|Auto DevOps will automatically build, test and deploy your application based on a predefined Continuous Integration and Delivery configuration. %{auto_devops_start}Learn more about Auto DevOps%{auto_devops_end}" -msgstr "" - msgid "GroupSettings|Badges" msgstr "" @@ -9175,6 +9547,9 @@ msgstr "" msgid "Groups can also be nested by creating %{subgroup_docs_link_start}subgroups%{subgroup_docs_link_end}." msgstr "" +msgid "Groups with access to %{strong_start}%{group_name}%{strong_end}" +msgstr "" + msgid "Groups with access to <strong>%{project_name}</strong>" msgstr "" @@ -9465,12 +9840,6 @@ msgstr "" msgid "Identifier" msgstr "" -msgid "Identify areas of the codebase associated with a lot of churn, which can indicate potential code hotspots." -msgstr "" - -msgid "Identify the most frequently changed files in your repository" -msgstr "" - msgid "Identities" msgstr "" @@ -9522,6 +9891,12 @@ msgstr "" msgid "Iglu registry URL (optional)" msgstr "" +msgid "Ignore" +msgstr "" + +msgid "Image %{imageName} was scheduled for deletion from the registry." +msgstr "" + msgid "ImageDiffViewer|2-up" msgstr "" @@ -9773,9 +10148,6 @@ msgid_plural "Instances" msgstr[0] "" msgstr[1] "" -msgid "Instance Statistics" -msgstr "" - msgid "Instance Statistics visibility" msgstr "" @@ -9866,6 +10238,9 @@ msgstr "" msgid "Invalid server response" msgstr "" +msgid "Invalid start or end time format" +msgstr "" + msgid "Invalid two-factor code." msgstr "" @@ -9887,6 +10262,15 @@ msgstr "" msgid "Invocations" msgstr "" +msgid "Is" +msgstr "" + +msgid "Is blocked by" +msgstr "" + +msgid "Is not" +msgstr "" + msgid "Is using license seat:" msgstr "" @@ -9983,6 +10367,9 @@ msgstr "" msgid "Issues closed" msgstr "" +msgid "Issues referenced by merge requests and commits within the default branch will be closed automatically" +msgstr "" + msgid "Issues with comments, merge requests with diffs and comments, labels, milestones, snippets, and other project entities" msgstr "" @@ -10106,6 +10493,12 @@ msgstr "" msgid "Job logs and artifacts" msgstr "" +msgid "Job to create self-monitoring project is in progress" +msgstr "" + +msgid "Job to delete self-monitoring project is in progress" +msgstr "" + msgid "Job was retried" msgstr "" @@ -10265,9 +10658,6 @@ msgstr "" msgid "Label" msgstr "" -msgid "Label List" -msgstr "" - msgid "Label actions dropdown" msgstr "" @@ -10333,6 +10723,9 @@ msgstr "" msgid "Last Accessed On" msgstr "" +msgid "Last Name is too long (maximum is %{max_length} characters)." +msgstr "" + msgid "Last Pipeline" msgstr "" @@ -10676,12 +11069,18 @@ msgid_plural "Limited to showing %d events at most" msgstr[0] "" msgstr[1] "" +msgid "Line changes" +msgstr "" + msgid "Link copied" msgstr "" msgid "Linked emails (%{email_count})" msgstr "" +msgid "Linked issues" +msgstr "" + msgid "LinkedIn" msgstr "" @@ -11048,6 +11447,9 @@ msgstr "" msgid "May" msgstr "" +msgid "Measured in bytes of code. Excludes generated and vendored code." +msgstr "" + msgid "Median" msgstr "" @@ -11078,6 +11480,9 @@ msgstr "" msgid "Memory Usage" msgstr "" +msgid "Memory limit exceeded while rendering template" +msgstr "" + msgid "Merge" msgstr "" @@ -11096,6 +11501,9 @@ msgstr "" msgid "Merge Requests created" msgstr "" +msgid "Merge Requests in Review" +msgstr "" + msgid "Merge commit message" msgstr "" @@ -11231,10 +11639,10 @@ msgstr "" msgid "MergeRequest|Error loading full diff. Please try again." msgstr "" -msgid "MergeRequest|Filter files or search with %{modifier_key}+p" +msgid "MergeRequest|No files found" msgstr "" -msgid "MergeRequest|No files found" +msgid "MergeRequest|Search files (%{modifier_key}P)" msgstr "" msgid "Merged" @@ -11297,6 +11705,9 @@ msgstr "" msgid "Metrics|Check out the CI/CD documentation on deploying to an environment" msgstr "" +msgid "Metrics|Create custom dashboard %{fileName}" +msgstr "" + msgid "Metrics|Create metric" msgstr "" @@ -11306,6 +11717,15 @@ msgstr "" msgid "Metrics|Delete metric?" msgstr "" +msgid "Metrics|Duplicate" +msgstr "" + +msgid "Metrics|Duplicate dashboard" +msgstr "" + +msgid "Metrics|Duplicating..." +msgstr "" + msgid "Metrics|Edit metric" msgstr "" @@ -11321,7 +11741,7 @@ msgstr "" msgid "Metrics|Legend label (optional)" msgstr "" -msgid "Metrics|Link contains an invalid time window." +msgid "Metrics|Link contains an invalid time window, please verify the link to see the requested time range." msgstr "" msgid "Metrics|Max" @@ -11342,6 +11762,12 @@ msgstr "" msgid "Metrics|Show last" msgstr "" +msgid "Metrics|There was an error creating the dashboard." +msgstr "" + +msgid "Metrics|There was an error creating the dashboard. %{error}" +msgstr "" + msgid "Metrics|There was an error fetching the environments data, please try again" msgstr "" @@ -11381,6 +11807,9 @@ msgstr "" msgid "Metrics|Y-axis label" msgstr "" +msgid "Metrics|You can save a copy of this dashboard to your repository so it can be customized. Select a file name and branch to save it." +msgstr "" + msgid "Metrics|You're about to permanently delete this metric. This cannot be undone." msgstr "" @@ -11545,6 +11974,9 @@ msgstr "" msgid "More actions" msgstr "" +msgid "More details" +msgstr "" + msgid "More info" msgstr "" @@ -11611,13 +12043,13 @@ msgstr "" msgid "Multiple uploaders found: %{uploader_types}" msgstr "" -msgid "Name" +msgid "My-Reaction" msgstr "" -msgid "Name has already been taken" +msgid "Name" msgstr "" -msgid "Name is too long (maximum is %{max_length} characters)." +msgid "Name has already been taken" msgstr "" msgid "Name new label" @@ -11671,6 +12103,9 @@ msgstr "" msgid "New Environment" msgstr "" +msgid "New Geo Node" +msgstr "" + msgid "New Group" msgstr "" @@ -11895,6 +12330,9 @@ msgstr "" msgid "No file chosen" msgstr "" +msgid "No file hooks found." +msgstr "" + msgid "No file selected" msgstr "" @@ -11907,9 +12345,6 @@ msgstr "" msgid "No forks are available to you." msgstr "" -msgid "No issues for the selected time period." -msgstr "" - msgid "No job log" msgstr "" @@ -11928,9 +12363,6 @@ msgstr "" msgid "No matching results" msgstr "" -msgid "No merge requests for the selected time period." -msgstr "" - msgid "No merge requests found" msgstr "" @@ -11958,9 +12390,6 @@ msgstr "" msgid "No public groups" msgstr "" -msgid "No pushes for the selected time period." -msgstr "" - msgid "No repository" msgstr "" @@ -12213,7 +12642,7 @@ msgstr "" msgid "Number of commits per MR" msgstr "" -msgid "Number of employees?" +msgid "Number of employees" msgstr "" msgid "Number of files touched" @@ -12314,9 +12743,6 @@ msgstr "" msgid "Only project members will be imported. Group members will be skipped." msgstr "" -msgid "Only these extensions are supported: %{extension_list}" -msgstr "" - msgid "Only users with an email address in this domain can be added to the group.<br>Example: <code>gitlab.com</code>. Some common domains are not allowed. %{read_more_link}." msgstr "" @@ -12413,6 +12839,9 @@ msgstr "" msgid "Optional" msgstr "" +msgid "Optional parameter \"variables\" must be an array of keys and values. Ex: [key1, value1, key2, value2]" +msgstr "" + msgid "Optionally, you can %{link_to_customize} how FogBugz email addresses and usernames are imported into GitLab." msgstr "" @@ -12458,16 +12887,25 @@ msgstr "" msgid "Owner" msgstr "" -msgid "Package deleted successfully" +msgid "Package deleted successfully" +msgstr "" + +msgid "Package information" +msgstr "" + +msgid "Package was removed" +msgstr "" + +msgid "PackageRegistry|Add Conan Remote" msgstr "" -msgid "Package information" +msgid "PackageRegistry|Conan Command" msgstr "" -msgid "Package was removed" +msgid "PackageRegistry|Copy Conan Command" msgstr "" -msgid "PackageRegistry|Automatically remove extra images that aren't designed to be kept." +msgid "PackageRegistry|Copy Conan Setup Command" msgstr "" msgid "PackageRegistry|Copy Maven XML" @@ -12500,6 +12938,9 @@ msgstr "" msgid "PackageRegistry|Delete package" msgstr "" +msgid "PackageRegistry|For more information on the Conan registry, %{linkStart}see the documentation%{linkEnd}." +msgstr "" + msgid "PackageRegistry|For more information on the Maven registry, %{linkStart}see the documentation%{linkEnd}." msgstr "" @@ -12509,9 +12950,6 @@ msgstr "" msgid "PackageRegistry|Installation" msgstr "" -msgid "PackageRegistry|Keep and protect the images that matter most." -msgstr "" - msgid "PackageRegistry|Learn how to %{noPackagesLinkStart}publish and share your packages%{noPackagesLinkEnd} with GitLab." msgstr "" @@ -12524,18 +12962,12 @@ msgstr "" msgid "PackageRegistry|Package installation" msgstr "" -msgid "PackageRegistry|Read more about the %{helpLinkStart}Container Registry tag retention policies%{helpLinkEnd}" -msgstr "" - msgid "PackageRegistry|Registry Setup" msgstr "" msgid "PackageRegistry|Remove package" msgstr "" -msgid "PackageRegistry|Tag retention policies are designed to:" -msgstr "" - msgid "PackageRegistry|There are no packages yet" msgstr "" @@ -12605,6 +13037,12 @@ msgstr "" msgid "Parameter" msgstr "" +msgid "Parameter \"job_id\" cannot exceed length of %{job_id_max_size}" +msgstr "" + +msgid "Parent" +msgstr "" + msgid "Parent epic doesn't exist." msgstr "" @@ -13205,7 +13643,10 @@ msgstr "" msgid "Preferences|Project overview content" msgstr "" -msgid "Preferences|Show whitespace in diffs" +msgid "Preferences|Render whitespace characters in the Web IDE" +msgstr "" + +msgid "Preferences|Show whitespace changes in diffs" msgstr "" msgid "Preferences|Sourcegraph" @@ -13241,9 +13682,6 @@ msgstr "" msgid "Press %{key}-C to copy" msgstr "" -msgid "Press Enter or click to search" -msgstr "" - msgid "Prevent adding new members to project membership within this group" msgstr "" @@ -13253,6 +13691,12 @@ msgstr "" msgid "Prevent approval of merge requests by merge request committers" msgstr "" +msgid "Prevent environment from auto-stopping" +msgstr "" + +msgid "Prevent users from changing their profile name" +msgstr "" + msgid "Preview" msgstr "" @@ -13319,6 +13763,9 @@ msgstr "" msgid "ProductivityAanalytics|Merge requests" msgstr "" +msgid "ProductivityAanalytics|is earlier than the allowed minimum date" +msgstr "" + msgid "ProductivityAnalytics|Ascending" msgstr "" @@ -13352,6 +13799,9 @@ msgstr "" msgid "ProductivityAnalytics|Trendline" msgstr "" +msgid "ProductivityAnalytics|is earlier than the given merged at after date" +msgstr "" + msgid "Profile" msgstr "" @@ -13550,6 +14000,9 @@ msgstr "" msgid "Profiles|Tell us about yourself in fewer than 250 characters" msgstr "" +msgid "Profiles|The ability to update your name has been disabled by your administrator." +msgstr "" + msgid "Profiles|The maximum file size allowed is 200KB." msgstr "" @@ -13706,6 +14159,9 @@ msgstr "" msgid "Project '%{project_name}' will be deleted on %{date}" msgstr "" +msgid "Project Analytics" +msgstr "" + msgid "Project Badges" msgstr "" @@ -13724,9 +14180,6 @@ msgstr "" msgid "Project access must be granted explicitly to each user." msgstr "" -msgid "Project already created" -msgstr "" - msgid "Project already deleted" msgstr "" @@ -13910,6 +14363,9 @@ msgstr "" msgid "ProjectSettings|All discussions must be resolved" msgstr "" +msgid "ProjectSettings|Allow users to make copies of your repository to a new project" +msgstr "" + msgid "ProjectSettings|Allow users to request access" msgstr "" @@ -13922,10 +14378,10 @@ msgstr "" msgid "ProjectSettings|Build, test, and deploy your changes" msgstr "" -msgid "ProjectSettings|Choose your merge method, merge options, and merge checks." +msgid "ProjectSettings|Choose your merge method, merge options, merge checks, and merge suggestions." msgstr "" -msgid "ProjectSettings|Choose your merge method, merge options, merge checks, and set up a default description template for merge requests." +msgid "ProjectSettings|Choose your merge method, merge options, merge checks, merge suggestions, and set up a default description template for merge requests." msgstr "" msgid "ProjectSettings|Contact an admin to change this setting." @@ -13970,6 +14426,9 @@ msgstr "" msgid "ProjectSettings|Fast-forward merges only" msgstr "" +msgid "ProjectSettings|Forks" +msgstr "" + msgid "ProjectSettings|Git Large File Storage" msgstr "" @@ -14009,6 +14468,9 @@ msgstr "" msgid "ProjectSettings|Merge requests" msgstr "" +msgid "ProjectSettings|Merge suggestions" +msgstr "" + msgid "ProjectSettings|No merge commits are created" msgstr "" @@ -14060,6 +14522,12 @@ msgstr "" msgid "ProjectSettings|Submit changes to be merged upstream" msgstr "" +msgid "ProjectSettings|The commit message used to apply merge request suggestions" +msgstr "" + +msgid "ProjectSettings|The variables GitLab supports:" +msgstr "" + msgid "ProjectSettings|These checks must pass before merge requests can be merged" msgstr "" @@ -14372,10 +14840,10 @@ msgstr "" msgid "Promote" msgstr "" -msgid "Promote issue to an epic" +msgid "Promote confidential issue to a non-confidential epic" msgstr "" -msgid "Promote issue to an epic." +msgid "Promote issue to an epic" msgstr "" msgid "Promote these project milestones into a group milestone." @@ -14396,6 +14864,9 @@ msgstr "" msgid "PromoteMilestone|Promotion failed - %{message}" msgstr "" +msgid "Promoted confidential issue to a non-confidential epic. Information in this issue is no longer confidential as epics are public to group members." +msgstr "" + msgid "Promoted issue to an epic." msgstr "" @@ -14720,6 +15191,9 @@ msgstr "" msgid "Recent searches" msgstr "" +msgid "Recipe" +msgstr "" + msgid "Recovery Codes" msgstr "" @@ -14809,6 +15283,9 @@ msgstr "" msgid "Related merge requests" msgstr "" +msgid "Relates to" +msgstr "" + msgid "Release" msgid_plural "Releases" msgstr[0] "" @@ -14826,10 +15303,10 @@ msgstr "" msgid "Releases" msgstr "" -msgid "Releases are based on Git tags. We recommend naming tags that fit within semantic versioning, for example %{codeStart}v1.0%{codeEnd}, %{codeStart}v2.0-pre%{codeEnd}." +msgid "Releases are based on Git tags and mark specific points in a project's development history. They can contain information about the type of changes and can also deliver binaries, like compiled versions of your software." msgstr "" -msgid "Releases mark specific points in a project's development history, communicate information about the type of change, and deliver on prepared, often compiled, versions of the software to be reused elsewhere. Currently, releases can only be created through the API." +msgid "Releases are based on Git tags. We recommend naming tags that fit within semantic versioning, for example %{codeStart}v1.0%{codeEnd}, %{codeStart}v2.0-pre%{codeEnd}." msgstr "" msgid "Release|Something went wrong while getting the release details" @@ -14904,15 +15381,24 @@ msgstr "" msgid "Remove milestone" msgstr "" +msgid "Remove node" +msgstr "" + msgid "Remove parent epic from an epic" msgstr "" +msgid "Remove primary node" +msgstr "" + msgid "Remove priority" msgstr "" msgid "Remove project" msgstr "" +msgid "Remove secondary node" +msgstr "" + msgid "Remove spent time" msgstr "" @@ -15171,12 +15657,18 @@ msgstr "" msgid "Request Access" msgstr "" +msgid "Request parameter %{param} is missing." +msgstr "" + msgid "Request to link SAML account must be authorized" msgstr "" msgid "Requested %{time_ago}" msgstr "" +msgid "Requested design version does not exist" +msgstr "" + msgid "Requests Profiles" msgstr "" @@ -15238,6 +15730,9 @@ msgstr "" msgid "Resetting the authorization key will invalidate the previous key. Existing alert configurations will need to be updated with the new key." msgstr "" +msgid "Resolve" +msgstr "" + msgid "Resolve all threads in new issue" msgstr "" @@ -15313,6 +15808,12 @@ msgstr "" msgid "Resume replication" msgstr "" +msgid "Resync" +msgstr "" + +msgid "Resync all designs" +msgstr "" + msgid "Retry" msgstr "" @@ -15345,9 +15846,21 @@ msgstr "" msgid "Review" msgstr "" +msgid "Review App|View app" +msgstr "" + +msgid "Review App|View latest app" +msgstr "" + msgid "Review the process for configuring service providers in your identity provider — in this case, GitLab is the \"service provider\" or \"relying party\"." msgstr "" +msgid "Review time" +msgstr "" + +msgid "Review time is defined as the time it takes from first comment until merged." +msgstr "" + msgid "Reviewing" msgstr "" @@ -15519,6 +16032,9 @@ msgstr "" msgid "Save comment" msgstr "" +msgid "Save expiration policy" +msgstr "" + msgid "Save password" msgstr "" @@ -15621,6 +16137,9 @@ msgstr "" msgid "Search for projects, issues, etc." msgstr "" +msgid "Search for this text" +msgstr "" + msgid "Search forks" msgstr "" @@ -15859,6 +16378,12 @@ msgstr "" msgid "Security dashboard" msgstr "" +msgid "Security report is out of date. Please incorporate latest changes from %{targetBranchName}" +msgstr "" + +msgid "Security report is out of date. Retry the pipeline for the target branch." +msgstr "" + msgid "SecurityConfiguration|Configured" msgstr "" @@ -16072,6 +16597,45 @@ msgstr "" msgid "Selecting a GitLab user will add a link to the GitLab user in the descriptions of issues and comments (e.g. \"By <a href=\"#\">@johnsmith</a>\"). It will also associate and/or assign these issues and comments with the selected user." msgstr "" +msgid "Self monitoring project does not exist" +msgstr "" + +msgid "Self-monitoring is not enabled on this GitLab server, contact your administrator." +msgstr "" + +msgid "Self-monitoring project does not exist. Please check logs for any error messages" +msgstr "" + +msgid "Self-monitoring project has been successfully deleted" +msgstr "" + +msgid "Self-monitoring project was not deleted. Please check logs for any error messages" +msgstr "" + +msgid "SelfMonitoring|Disable self monitoring?" +msgstr "" + +msgid "SelfMonitoring|Disabling this feature will delete the self monitoring project. Are you sure you want to delete the project?" +msgstr "" + +msgid "SelfMonitoring|Enable or disable instance self monitoring" +msgstr "" + +msgid "SelfMonitoring|Enabling this feature creates a %{projectLinkStart}project%{projectLinkEnd} that can be used to monitor the health of your instance." +msgstr "" + +msgid "SelfMonitoring|Enabling this feature creates a project that can be used to monitor the health of your instance." +msgstr "" + +msgid "SelfMonitoring|Self monitoring" +msgstr "" + +msgid "SelfMonitoring|Self monitoring project has been successfully created." +msgstr "" + +msgid "SelfMonitoring|Self monitoring project has been successfully deleted." +msgstr "" + msgid "Send a separate email notification to Developers." msgstr "" @@ -16554,6 +17118,9 @@ msgstr "" msgid "Size limit per repository (MB)" msgstr "" +msgid "Size settings for static websites" +msgstr "" + msgid "Skip Trial (Continue with Free Account)" msgstr "" @@ -16734,6 +17301,9 @@ msgstr "" msgid "Something went wrong while stopping this environment. Please try again." msgstr "" +msgid "Something went wrong while updating your list settings" +msgstr "" + msgid "Something went wrong, unable to add %{project} to dashboard" msgstr "" @@ -17007,9 +17577,6 @@ msgstr "" msgid "Stage all changes" msgstr "" -msgid "Stage changes" -msgstr "" - msgid "Stage data updated" msgstr "" @@ -17112,6 +17679,9 @@ msgstr "" msgid "Start thread & reopen %{noteable_name}" msgstr "" +msgid "Start your Free Gold Trial" +msgstr "" + msgid "Start your free trial" msgstr "" @@ -17211,6 +17781,9 @@ msgstr "" msgid "Subkeys" msgstr "" +msgid "Submit a review" +msgstr "" + msgid "Submit as spam" msgstr "" @@ -17226,6 +17799,12 @@ msgstr "" msgid "Submit search" msgstr "" +msgid "Submit the current review." +msgstr "" + +msgid "Submitted the current review." +msgstr "" + msgid "Subscribe" msgstr "" @@ -17373,7 +17952,7 @@ msgstr "" msgid "Successfully unlocked" msgstr "" -msgid "Suggest code changes which are immediately applied. Try it out!" +msgid "Suggest code changes which can be immediately applied in one click. Try it out!" msgstr "" msgid "Suggested change" @@ -17616,6 +18195,9 @@ msgstr "" msgid "Target branch" msgstr "" +msgid "Target-Branch" +msgstr "" + msgid "Team" msgstr "" @@ -17771,6 +18353,9 @@ msgstr "" msgid "The branch for this project has no active pipeline configuration." msgstr "" +msgid "The branch or tag does not exist" +msgstr "" + msgid "The character highlighter helps you keep the subject line to %{titleLength} characters and wrap the body at %{bodyLength} so they are readable in git." msgstr "" @@ -17780,6 +18365,9 @@ msgstr "" msgid "The collection of events added to the data gathered for that stage." msgstr "" +msgid "The commit does not exist" +msgstr "" + msgid "The configuration status of the table below only applies to the default branch and is based on the %{linkStart}latest pipeline%{linkEnd}. Once you've configured a scan for the default branch, any subsequent feature branch you create will include the scan." msgstr "" @@ -17789,6 +18377,9 @@ msgstr "" msgid "The content of this page is not encoded in UTF-8. Edits can only be made via the Git repository." msgstr "" +msgid "The current issue" +msgstr "" + msgid "The data source is connected, but there is no data to display. %{documentationLink}" msgstr "" @@ -17819,6 +18410,9 @@ msgstr "" msgid "The file has been successfully deleted." msgstr "" +msgid "The file name should have a .yml extension" +msgstr "" + msgid "The following items will NOT be exported:" msgstr "" @@ -17896,7 +18490,7 @@ msgstr "" msgid "The merge request can now be merged." msgstr "" -msgid "The name %{entryName} is already taken in this directory." +msgid "The name \"%{name}\" is already taken in this directory." msgstr "" msgid "The number of changes to be fetched from GitLab when cloning a repository. This can speed up Pipelines execution. Keep empty or set to 0 to disable shallow clone by default and make GitLab CI fetch all branches and tags each time." @@ -17914,6 +18508,9 @@ msgstr "" msgid "The phase of the development lifecycle." msgstr "" +msgid "The pipeline has been deleted" +msgstr "" + msgid "The pipelines schedule runs pipelines in the future, repeatedly, for specific branches or tags. Those scheduled pipelines will inherit limited project access based on their associated user." msgstr "" @@ -18010,6 +18607,9 @@ msgstr "" msgid "The time taken by each data entry gathered by that stage." msgstr "" +msgid "The total stage shows the time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle." +msgstr "" + msgid "The unique identifier for the Geo node. Must match `geo_node_name` if it is set in gitlab.rb, otherwise it must match `external_url` with a trailing slash" msgstr "" @@ -18124,6 +18724,12 @@ msgstr "" msgid "There was an error adding a To Do." msgstr "" +msgid "There was an error creating the dashboard, branch name is invalid." +msgstr "" + +msgid "There was an error creating the dashboard, branch named: %{branch} already exists." +msgstr "" + msgid "There was an error creating the issue" msgstr "" @@ -18136,15 +18742,18 @@ msgstr "" msgid "There was an error fetching cycle analytics stages." msgstr "" -msgid "There was an error fetching data for the chart" +msgid "There was an error fetching data for the selected stage" msgstr "" -msgid "There was an error fetching data for the selected stage" +msgid "There was an error fetching data for the tasks by type chart" msgstr "" msgid "There was an error fetching label data for the selected group" msgstr "" +msgid "There was an error fetching median data for stages" +msgstr "" + msgid "There was an error fetching the Designs" msgstr "" @@ -18175,6 +18784,9 @@ msgstr "" msgid "There was an error subscribing to this label." msgstr "" +msgid "There was an error syncing the Design Repositories." +msgstr "" + msgid "There was an error trying to validate your query" msgstr "" @@ -18193,6 +18805,9 @@ msgstr "" msgid "There was an error while fetching cycle analytics duration data." msgstr "" +msgid "There was an error while fetching cycle analytics duration median data." +msgstr "" + msgid "There was an error while fetching cycle analytics summary data." msgstr "" @@ -18265,9 +18880,6 @@ msgstr "" msgid "This commit was signed with an <strong>unverified</strong> signature." msgstr "" -msgid "This container registry has been scheduled for deletion." -msgstr "" - msgid "This date is after the due date, so this epic won't appear in the roadmap." msgstr "" @@ -18421,6 +19033,9 @@ msgstr "" msgid "This job is stuck because you don't have any active runners that can run this job." msgstr "" +msgid "This job is waiting for resource: " +msgstr "" + msgid "This job requires a manual action" msgstr "" @@ -18460,6 +19075,12 @@ msgstr "" msgid "This pipeline makes use of a predefined CI/CD configuration enabled by <b>Auto DevOps.</b>" msgstr "" +msgid "This pipeline triggered a child pipeline" +msgstr "" + +msgid "This pipeline was triggered by a parent pipeline" +msgstr "" + msgid "This project" msgstr "" @@ -18535,12 +19156,24 @@ msgstr "" msgid "ThreatMonitoring|A Web Application Firewall (WAF) provides monitoring and rules to protect production applications. GitLab adds the modsecurity WAF plug-in when you install the Ingress app in your Kubernetes cluster." msgstr "" +msgid "ThreatMonitoring|Anomalous Requests" +msgstr "" + msgid "ThreatMonitoring|At this time, threat monitoring only supports WAF data." msgstr "" msgid "ThreatMonitoring|Environment" msgstr "" +msgid "ThreatMonitoring|No traffic to display" +msgstr "" + +msgid "ThreatMonitoring|Requests" +msgstr "" + +msgid "ThreatMonitoring|Show last" +msgstr "" + msgid "ThreatMonitoring|Something went wrong, unable to fetch WAF statistics" msgstr "" @@ -18556,12 +19189,18 @@ msgstr "" msgid "ThreatMonitoring|Threat Monitoring help page link" msgstr "" -msgid "ThreatMonitoring|View WAF documentation" +msgid "ThreatMonitoring|Time" +msgstr "" + +msgid "ThreatMonitoring|Total Requests" msgstr "" msgid "ThreatMonitoring|Web Application Firewall not enabled" msgstr "" +msgid "ThreatMonitoring|While it's rare to have no traffic coming to your application, it can happen. In any event, we ask that you double check your settings to make sure you've set up the WAF correctly." +msgstr "" + msgid "Thursday" msgstr "" @@ -18992,9 +19631,6 @@ msgstr "" msgid "Total Contributions" msgstr "" -msgid "Total Time" -msgstr "" - msgid "Total artifacts size: %{total_size}" msgstr "" @@ -19316,9 +19952,6 @@ msgstr "" msgid "Unstage all changes" msgstr "" -msgid "Unstage changes" -msgstr "" - msgid "Unstaged" msgstr "" @@ -19595,6 +20228,9 @@ msgstr "" msgid "Used to help configure your identity provider" msgstr "" +msgid "User" +msgstr "" + msgid "User %{current_user_username} has started impersonating %{username}" msgstr "" @@ -19631,6 +20267,9 @@ msgstr "" msgid "User pipeline minutes were successfully reset." msgstr "" +msgid "User restrictions" +msgstr "" + msgid "User settings" msgstr "" @@ -19889,6 +20528,9 @@ msgstr "" msgid "Username is available." msgstr "" +msgid "Username is too long (maximum is %{max_length} characters)." +msgstr "" + msgid "Username or email" msgstr "" @@ -19982,7 +20624,10 @@ msgstr "" msgid "Very helpful" msgstr "" -msgid "View app" +msgid "View Documentation" +msgstr "" + +msgid "View blame prior to this change" msgstr "" msgid "View dependency details for your project" @@ -20017,6 +20662,9 @@ msgstr "" msgid "View group labels" msgstr "" +msgid "View issue" +msgstr "" + msgid "View it on GitLab" msgstr "" @@ -20038,7 +20686,7 @@ msgstr "" msgid "View open merge request" msgstr "" -msgid "View previous app" +msgid "View project" msgstr "" msgid "View project labels" @@ -20050,6 +20698,9 @@ msgstr "" msgid "View the documentation" msgstr "" +msgid "View the latest successful deployment to this environment" +msgstr "" + msgid "Viewing commit" msgstr "" @@ -20170,6 +20821,9 @@ msgstr "" msgid "Vulnerability|Severity" msgstr "" +msgid "WIP" +msgstr "" + msgid "Wait for the file to load to copy its contents" msgstr "" @@ -20179,6 +20833,9 @@ msgstr "" msgid "Want to see the data? Please ask an administrator for access." msgstr "" +msgid "Warning:" +msgstr "" + msgid "We could not determine the path to remove the epic" msgstr "" @@ -20203,9 +20860,6 @@ msgstr "" msgid "We heard back from your U2F device. You have been authenticated." msgstr "" -msgid "We need some additional information to activate your free trial" -msgstr "" - msgid "We sent you an email with reset password instructions" msgstr "" @@ -20251,7 +20905,7 @@ msgstr "" msgid "Welcome to GitLab" msgstr "" -msgid "Welcome to GitLab @%{username}!" +msgid "Welcome to GitLab %{name}!" msgstr "" msgid "Welcome to the Guided GitLab Tour" @@ -20266,6 +20920,9 @@ msgstr "" msgid "When a runner is locked, it cannot be assigned to other projects" msgstr "" +msgid "When enabled, any user visiting %{host} will be able to create an account." +msgstr "" + msgid "When enabled, users cannot use GitLab until the terms have been accepted." msgstr "" @@ -20460,6 +21117,9 @@ msgstr "" msgid "Withdraw Access Request" msgstr "" +msgid "Work in Progress Limit" +msgstr "" + msgid "Workflow Help" msgstr "" @@ -20643,6 +21303,9 @@ msgstr "" msgid "You can try again using %{begin_link}basic search%{end_link}" msgstr "" +msgid "You can't commit to this project" +msgstr "" + msgid "You cannot access the raw file. Please wait a minute." msgstr "" @@ -20892,6 +21555,9 @@ msgstr "" msgid "Your GPG keys (%{count})" msgstr "" +msgid "Your Gitlab Gold trial will last 30 days after which point you can keep your free Gitlab account forever. We just need some additional information to activate your trial." +msgstr "" + msgid "Your Groups" msgstr "" @@ -20967,6 +21633,9 @@ msgstr "" msgid "Your comment could not be updated! Please check your network connection and try again." msgstr "" +msgid "Your dashboard has been copied. You can %{web_ide_link_start}edit it here%{web_ide_link_end}." +msgstr "" + msgid "Your deployment services will be broken, you will need to manually fix the services after renaming." msgstr "" @@ -21021,6 +21690,11 @@ msgstr "" msgid "a design" msgstr "" +msgid "about 1 hour" +msgid_plural "about %d hours" +msgstr[0] "" +msgstr[1] "" + msgid "added %{created_at_timeago}" msgstr "" @@ -21036,19 +21710,22 @@ msgstr "" msgid "already being used for another group or project milestone." msgstr "" +msgid "already has a \"created\" issue link" +msgstr "" + msgid "already shared with this group" msgstr "" msgid "among other things" msgstr "" -msgid "archived" +msgid "and" msgstr "" -msgid "assign yourself" +msgid "archived" msgstr "" -msgid "at line %{errorLine}%{errorColumn}" +msgid "assign yourself" msgstr "" msgid "attach a new file" @@ -21057,6 +21734,9 @@ msgstr "" msgid "authored" msgstr "" +msgid "blocks" +msgstr "" + msgid "branch name" msgstr "" @@ -21151,14 +21831,6 @@ msgstr "" msgid "ciReport|%{reportType} %{status} detected no vulnerabilities for the source branch only" msgstr "" -msgid "ciReport|%{reportType} detected %{vulnerabilityCount} vulnerability" -msgid_plural "ciReport|%{reportType} detected %{vulnerabilityCount} vulnerabilities" -msgstr[0] "" -msgstr[1] "" - -msgid "ciReport|%{reportType} detected no vulnerabilities" -msgstr "" - msgid "ciReport|%{reportType} is loading" msgstr "" @@ -21318,18 +21990,6 @@ msgstr "" msgid "ciReport|There was an error dismissing the vulnerability. Please try again." msgstr "" -msgid "ciReport|There was an error loading DAST report" -msgstr "" - -msgid "ciReport|There was an error loading SAST report" -msgstr "" - -msgid "ciReport|There was an error loading container scanning report" -msgstr "" - -msgid "ciReport|There was an error loading dependency scanning report" -msgstr "" - msgid "ciReport|There was an error reverting the dismissal. Please try again." msgstr "" @@ -21433,6 +22093,9 @@ msgstr "" msgid "disabled" msgstr "" +msgid "does not have a supported extension. Only %{extension_list} are supported" +msgstr "" + msgid "done" msgstr "" @@ -21486,6 +22149,11 @@ msgstr "" msgid "failed to dismiss associated finding(id=%{finding_id}): %{message}" msgstr "" +msgid "file" +msgid_plural "files" +msgstr[0] "" +msgstr[1] "" + msgid "finding is not found or is already attached to a vulnerability" msgstr "" @@ -21513,6 +22181,9 @@ msgstr "" msgid "group" msgstr "" +msgid "has already been linked to another vulnerability" +msgstr "" + msgid "has already been taken" msgstr "" @@ -21534,9 +22205,6 @@ msgstr "" msgid "importing" msgstr "" -msgid "in %{errorFn} " -msgstr "" - msgid "in group %{link_to_group}" msgstr "" @@ -21557,6 +22225,9 @@ msgstr "" msgid "is an invalid IP address range" msgstr "" +msgid "is blocked by" +msgstr "" + msgid "is enabled." msgstr "" @@ -21611,6 +22282,12 @@ msgstr "" msgid "leave %{group_name}" msgstr "" +msgid "less than a minute" +msgstr "" + +msgid "level: %{level}" +msgstr "" + msgid "limit of %{project_limit} reached" msgstr "" @@ -21629,9 +22306,6 @@ msgstr "" msgid "math|There was an error rendering this math block" msgstr "" -msgid "may expose confidential information" -msgstr "" - msgid "merge request" msgid_plural "merge requests" msgstr[0] "" @@ -21943,6 +22617,9 @@ msgstr "" msgid "needs to be between 10 minutes and 1 month" msgstr "" +msgid "never expires" +msgstr "" + msgid "new merge request" msgstr "" @@ -21973,6 +22650,9 @@ msgstr "" msgid "opened %{timeAgoString} by %{user}" msgstr "" +msgid "opened %{timeAgo}" +msgstr "" + msgid "out of %d total test" msgid_plural "out of %d total tests" msgstr[0] "" @@ -22026,6 +22706,9 @@ msgstr "" msgid "register" msgstr "" +msgid "relates to" +msgstr "" + msgid "released %{time}" msgstr "" @@ -22142,6 +22825,9 @@ msgstr "" msgid "tag name" msgstr "" +msgid "the following issue(s)" +msgstr "" + msgid "this document" msgstr "" @@ -22225,5 +22911,11 @@ msgstr "" msgid "with %{additions} additions, %{deletions} deletions." msgstr "" +msgid "with expiry changing from %{old_expiry} to %{new_expiry}" +msgstr "" + +msgid "with expiry remaining unchanged at %{old_expiry}" +msgstr "" + msgid "yaml invalid" msgstr "" diff --git a/package.json b/package.json index a4b6f55365ebcc767d5d1488ae476df8c48759ba..df3c28ada2a93e5eed4dc60368e7f4069534ad11 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,8 @@ "check-dependencies": "scripts/frontend/check_dependencies.sh", "clean": "rm -rf public/assets tmp/cache/*-loader", "dev-server": "NODE_OPTIONS=\"--max-old-space-size=3584\" nodemon -w 'config/webpack.config.js' --exec 'webpack-dev-server --config config/webpack.config.js'", - "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": "eslint --cache --max-warnings 0 --report-unused-disable-directives --ext .js,.vue .", + "eslint-fix": "eslint --cache --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 .", "file-coverage": "scripts/frontend/file_test_coverage.js", "prejest": "yarn check-dependencies", @@ -22,8 +22,8 @@ "prettier-staged-save": "node ./scripts/frontend/prettier.js save", "prettier-all": "node ./scripts/frontend/prettier.js check-all", "prettier-all-save": "node ./scripts/frontend/prettier.js save-all", - "stylelint": "node node_modules/stylelint/bin/stylelint.js app/assets/stylesheets/**/*.* ee/app/assets/stylesheets/**/*.* !**/vendors/**", - "stylelint-file": "node node_modules/stylelint/bin/stylelint.js", + "stylelint": "yarn stylelint-file app/assets/stylesheets/**/*.* ee/app/assets/stylesheets/**/*.* !**/vendors/**", + "stylelint-file": "BROWSERSLIST_IGNORE_OLD_DATA=true node node_modules/stylelint/bin/stylelint.js", "stylelint-create-utility-map": "node scripts/frontend/stylelint/stylelint-utility-map.js", "test": "node scripts/frontend/test", "webpack": "NODE_OPTIONS=\"--max-old-space-size=3584\" webpack --config config/webpack.config.js", @@ -39,11 +39,11 @@ "@babel/plugin-syntax-dynamic-import": "^7.2.0", "@babel/plugin-syntax-import-meta": "^7.2.0", "@babel/preset-env": "^7.6.2", - "@gitlab/svgs": "^1.88.0", - "@gitlab/ui": "8.8.0", + "@gitlab/svgs": "^1.89.0", + "@gitlab/ui": "8.17.0", "@gitlab/visual-review-tools": "1.5.1", "@sentry/browser": "^5.10.2", - "@sourcegraph/code-host-integration": "^0.0.14", + "@sourcegraph/code-host-integration": "^0.0.18", "apollo-cache-inmemory": "^1.6.3", "apollo-client": "^2.6.4", "apollo-link": "^1.2.11", @@ -70,7 +70,7 @@ "d3-scale": "^1.0.7", "d3-selection": "^1.2.0", "dateformat": "^3.0.3", - "deckar01-task_list": "^2.2.1", + "deckar01-task_list": "^2.3.1", "diff": "^3.4.0", "document-register-element": "1.13.1", "dropzone": "^4.2.0", @@ -82,6 +82,7 @@ "fuzzaldrin-plus": "^0.5.0", "glob": "^7.1.2", "graphql": "^14.0.2", + "immer": "^5.2.1", "imports-loader": "^0.8.0", "jed": "^1.1.1", "jest-transform-graphql": "^2.1.0", @@ -94,8 +95,8 @@ "jszip-utils": "^0.0.2", "katex": "^0.10.0", "marked": "^0.3.12", - "mermaid": "^8.4.2", - "monaco-editor": "^0.15.6", + "mermaid": "^8.4.5", + "monaco-editor": "^0.18.1", "monaco-editor-webpack-plugin": "^1.7.0", "mousetrap": "^1.4.6", "pdfjs-dist": "^2.0.943", @@ -131,10 +132,10 @@ "vue-loader": "^15.7.1", "vue-router": "^3.0.2", "vue-template-compiler": "^2.6.10", - "vue-virtual-scroll-list": "^1.3.1", + "vue-virtual-scroll-list": "^1.4.4", "vuedraggable": "^2.23.0", "vuex": "^3.1.0", - "webpack": "^4.40.2", + "webpack": "^4.41.5", "webpack-bundle-analyzer": "^3.5.1", "webpack-cli": "^3.3.9", "webpack-stats-plugin": "^0.3.0", @@ -146,7 +147,7 @@ "@gitlab/eslint-config": "^2.0.0", "@gitlab/eslint-plugin-i18n": "^1.1.0", "@gitlab/eslint-plugin-vue-i18n": "^1.2.0", - "@vue/test-utils": "^1.0.0-beta.25", + "@vue/test-utils": "^1.0.0-beta.30", "axios-mock-adapter": "^1.15.0", "babel-jest": "^24.1.0", "babel-plugin-dynamic-import-node": "^2.2.0", @@ -200,7 +201,8 @@ "yarn-deduplicate": "^1.1.1" }, "resolutions": { - "vue-jest/ts-jest": "24.0.0" + "vue-jest/ts-jest": "24.0.0", + "monaco-editor": "0.18.1" }, "engines": { "node": ">=8.10.0", diff --git a/public/robots.txt b/public/robots.txt index 2cda837d6f137bfd99e06436758b01caf12a5bd1..130328f568767ff694ccd59ecb5b26b687548c2c 100644 --- a/public/robots.txt +++ b/public/robots.txt @@ -72,3 +72,5 @@ Disallow: /*/*/protected_branches Disallow: /*/*/uploads/ Disallow: /*/-/group_members Disallow: /*/project_members +Disallow: /groups/*/-/contribution_analytics +Disallow: /groups/*/-/analytics diff --git a/qa/Dockerfile b/qa/Dockerfile index e4b860b08b27600efd1b877b79f671614315db49..126d9fbc5911d23186e7953aa57f387b66513b64 100644 --- a/qa/Dockerfile +++ b/qa/Dockerfile @@ -38,6 +38,12 @@ RUN apt-get update -q && apt-get install -y google-chrome-stable && apt-get clea RUN wget -q https://chromedriver.storage.googleapis.com/$(wget -q -O - https://chromedriver.storage.googleapis.com/LATEST_RELEASE)/chromedriver_linux64.zip RUN unzip chromedriver_linux64.zip -d /usr/local/bin +## +# Install K3d local cluster support +# https://github.com/rancher/k3d +# +RUN curl -s https://raw.githubusercontent.com/rancher/k3d/master/install.sh | TAG=v1.3.4 bash + ## # Install gcloud and kubectl CLI used in Auto DevOps test to create K8s # clusters diff --git a/qa/Gemfile b/qa/Gemfile index 3575ecf13e924b752ab1b7be0b1eaa08449fb6ed..58118340f248b9e9a6ccaa89265ea9eeadbce237 100644 --- a/qa/Gemfile +++ b/qa/Gemfile @@ -19,4 +19,5 @@ group :test do gem 'pry-byebug', '~> 3.5.1', platform: :mri gem "ruby-debug-ide", "~> 0.7.0" gem "debase", "~> 0.2.4.1" + gem 'timecop', '~> 0.9.1' end diff --git a/qa/Gemfile.lock b/qa/Gemfile.lock index 25c7703ef527e5276c73369c5322177ffcd3d172..6d48a9449a52bf6dd76b3c1f82ff8a9c01d0d8da 100644 --- a/qa/Gemfile.lock +++ b/qa/Gemfile.lock @@ -99,6 +99,7 @@ GEM childprocess (>= 0.5, < 4.0) rubyzip (>= 1.2.2) thread_safe (0.3.6) + timecop (0.9.1) tzinfo (1.2.5) thread_safe (~> 0.1) unf (0.1.4) @@ -128,6 +129,7 @@ DEPENDENCIES rspec_junit_formatter (~> 0.4.1) ruby-debug-ide (~> 0.7.0) selenium-webdriver (~> 3.12) + timecop (~> 0.9.1) BUNDLED WITH 1.17.3 diff --git a/qa/README.md b/qa/README.md index 332e5c8170fb6039009bae88f28b5b5161193f82..1bfa83cadf1a71a055ec38b9ff2895f82af46603 100644 --- a/qa/README.md +++ b/qa/README.md @@ -30,7 +30,7 @@ and corresponding views / partials / selectors in CE / EE. Whenever `qa:selectors` job fails in your merge request, you are supposed to fix [page objects](../doc/development/testing_guide/end_to_end/page_objects.md). You should also trigger end-to-end tests -using `package-and-qa-manual` manual action, to test if everything works fine. +using `package-and-qa` manual action, to test if everything works fine. ## How can I use it? diff --git a/qa/load/artillery.yml b/qa/load/artillery.yml deleted file mode 100644 index 17d253ec480951139152c357eb4c9c506128ae89..0000000000000000000000000000000000000000 --- a/qa/load/artillery.yml +++ /dev/null @@ -1,25 +0,0 @@ -config: - target: "{{ $processEnvironment.HOST_URL }}" - http: - pool: 10 # All HTTP requests from all virtual users will be sent over the same <pool> connections. - # This also means that there is a limit on the number of requests sent per second. - phases: - - duration: 30 - arrivalRate: 10 - name: "Warm up" - - duration: 90 - arrivalRate: 10 - rampTo: 100 - name: "Gradual ramp up" - - duration: 90 - arrivalRate: 100 - name: "Sustained max load" -scenarios: - - name: "Visit large issue url" - flow: - - get: - url: "{{ $processEnvironment.LARGE_ISSUE_URL }}" - - name: "Visit large MR url" - flow: - - get: - url: "{{ $processEnvironment.LARGE_MR_URL }}" diff --git a/qa/qa.rb b/qa/qa.rb index 509de4af79c30888ca48634a64140899c1361b42..a0ce6caa3a918995f2926d451d6df8189cdd2d1c 100644 --- a/qa/qa.rb +++ b/qa/qa.rb @@ -37,6 +37,7 @@ module QA autoload :MailHog, 'qa/runtime/mail_hog' autoload :IPAddress, 'qa/runtime/ip_address' autoload :Search, 'qa/runtime/search' + autoload :ApplicationSettings, 'qa/runtime/application_settings' module API autoload :Client, 'qa/runtime/api/client' @@ -487,8 +488,10 @@ module QA end autoload :Api, 'qa/support/api' autoload :Dates, 'qa/support/dates' - autoload :Waiter, 'qa/support/waiter' + autoload :Repeater, 'qa/support/repeater' autoload :Retrier, 'qa/support/retrier' + autoload :Waiter, 'qa/support/waiter' + autoload :WaitForRequests, 'qa/support/wait_for_requests' end end diff --git a/qa/qa/fixtures/auto_devops_rack/Dockerfile b/qa/qa/fixtures/auto_devops_rack/Dockerfile index 1f59c23ea8898408cfd4881290494e8edc736569..6ab2795dd40aa68ed089d0e320e94b34d59cee1e 100644 --- a/qa/qa/fixtures/auto_devops_rack/Dockerfile +++ b/qa/qa/fixtures/auto_devops_rack/Dockerfile @@ -1,4 +1,4 @@ -FROM ruby:2.6.3-alpine +FROM ruby:2.6.5-alpine ADD ./ /app/ WORKDIR /app ENV RACK_ENV production diff --git a/qa/qa/page/base.rb b/qa/qa/page/base.rb index dcba4fc8544f35cd34d8c40d13bb8a1478db7593..a4c44f78ad422509f3dc54258404fdbc961bd42c 100644 --- a/qa/qa/page/base.rb +++ b/qa/qa/page/base.rb @@ -8,6 +8,7 @@ module QA prepend Support::Page::Logging if Runtime::Env.debug? include Capybara::DSL include Scenario::Actable + include Support::WaitForRequests extend Validatable extend SingleForwardable @@ -21,27 +22,31 @@ module QA def refresh page.refresh + + wait_for_requests end - def wait(max: 60, interval: 0.1, reload: true) - QA::Support::Waiter.wait(max: max, interval: interval) do + def wait_until(max_duration: 60, sleep_interval: 0.1, reload: true, raise_on_failure: false) + Support::Waiter.wait_until(max_duration: max_duration, sleep_interval: sleep_interval, raise_on_failure: raise_on_failure) do yield || (reload && refresh && false) end end - def retry_until(max_attempts: 3, reload: false, sleep_interval: 0) - QA::Support::Retrier.retry_until(max_attempts: max_attempts, reload_page: (reload && self), sleep_interval: sleep_interval) do + def retry_until(max_attempts: 3, reload: false, sleep_interval: 0, raise_on_failure: false) + Support::Retrier.retry_until(max_attempts: max_attempts, reload_page: (reload && self), sleep_interval: sleep_interval, raise_on_failure: raise_on_failure) do yield end end def retry_on_exception(max_attempts: 3, reload: false, sleep_interval: 0.5) - QA::Support::Retrier.retry_on_exception(max_attempts: max_attempts, reload_page: (reload && self), sleep_interval: sleep_interval) do + Support::Retrier.retry_on_exception(max_attempts: max_attempts, reload_page: (reload && self), sleep_interval: sleep_interval) do yield end end def scroll_to(selector, text: nil) + wait_for_requests + page.execute_script <<~JS var elements = Array.from(document.querySelectorAll('#{selector}')); var text = '#{text}'; @@ -66,7 +71,7 @@ module QA xhr.send(); JS - return false unless wait(interval: 0.5, max: 60, reload: false) do + return false unless wait_until(sleep_interval: 0.5, max_duration: 60, reload: false) do page.evaluate_script('xhr.readyState == XMLHttpRequest.DONE') end @@ -74,6 +79,8 @@ module QA end def find_element(name, **kwargs) + wait_for_requests + find(element_selector_css(name), kwargs) end @@ -82,6 +89,12 @@ module QA end def all_elements(name, **kwargs) + if kwargs.keys.none? { |key| [:minimum, :maximum, :count, :between].include?(key) } + raise ArgumentError, "Please use :minimum, :maximum, :count, or :between so that all is more reliable" + end + + wait_for_requests + all(element_selector_css(name), **kwargs) end @@ -102,8 +115,8 @@ module QA end # replace with (..., page = self.class) - def click_element(name, page = nil, text: nil) - find_element(name, text: text).click + def click_element(name, page = nil, text: nil, wait: Capybara.default_max_wait_time) + find_element(name, text: text, wait: wait).click page.validate_elements_present! if page end @@ -119,33 +132,48 @@ module QA element.select value end + def has_active_element?(name, **kwargs) + has_element?(name, class: 'active', **kwargs) + end + def has_element?(name, **kwargs) - wait = kwargs[:wait] ? kwargs[:wait] && kwargs.delete(:wait) : Capybara.default_max_wait_time - text = kwargs[:text] ? kwargs[:text] && kwargs.delete(:text) : nil + wait_for_requests - has_css?(element_selector_css(name, kwargs), text: text, wait: wait) + wait = kwargs.delete(:wait) || Capybara.default_max_wait_time + text = kwargs.delete(:text) + klass = kwargs.delete(:class) + + has_css?(element_selector_css(name, kwargs), text: text, wait: wait, class: klass) end def has_no_element?(name, **kwargs) - wait = kwargs[:wait] ? kwargs[:wait] && kwargs.delete(:wait) : Capybara.default_max_wait_time - text = kwargs[:text] ? kwargs[:text] && kwargs.delete(:text) : nil + wait_for_requests + + wait = kwargs.delete(:wait) || Capybara.default_max_wait_time + text = kwargs.delete(:text) has_no_css?(element_selector_css(name, kwargs), wait: wait, text: text) end def has_text?(text, wait: Capybara.default_max_wait_time) + wait_for_requests + page.has_text?(text, wait: wait) end - def has_no_text?(text) - page.has_no_text? text + def has_no_text?(text, wait: Capybara.default_max_wait_time) + wait_for_requests + + page.has_no_text?(text, wait: wait) end def has_normalized_ws_text?(text, wait: Capybara.default_max_wait_time) - page.has_text?(text.gsub(/\s+/, " "), wait: wait) + has_text?(text.gsub(/\s+/, " "), wait: wait) end def finished_loading? + wait_for_requests + # The number of selectors should be able to be reduced after # migration to the new spinner is complete. # https://gitlab.com/groups/gitlab-org/-/epics/956 @@ -153,6 +181,8 @@ module QA end def finished_loading_block? + wait_for_requests + has_no_css?('.fa-spinner.block-loading', wait: Capybara.default_max_wait_time) end @@ -161,7 +191,7 @@ module QA # This loop gives time for the img tags to be rendered and for # images to start loading. previous_total_images = 0 - wait(interval: 1) do + wait_until(sleep_interval: 1) do current_total_images = all("img").size result = previous_total_images == current_total_images previous_total_images = current_total_images @@ -220,10 +250,14 @@ module QA end def click_link_with_text(text) + wait_for_requests + click_link text end def click_body + wait_for_requests + find('body').click end diff --git a/qa/qa/page/component/ci_badge_link.rb b/qa/qa/page/component/ci_badge_link.rb index aad8dc1d3df0eb95632e2def09135bbb3939b7bb..d3e44fd867dc7c0368a22dcf2cb7a5b75189b1e3 100644 --- a/qa/qa/page/component/ci_badge_link.rb +++ b/qa/qa/page/component/ci_badge_link.rb @@ -26,7 +26,7 @@ module QA private def completed?(timeout: 60) - wait(reload: false, max: timeout) do + wait_until(reload: false, max_duration: timeout) do COMPLETED_STATUSES.include?(status_badge) end end diff --git a/qa/qa/page/component/clone_panel.rb b/qa/qa/page/component/clone_panel.rb index b80877f5ecd76067c32196fd4b7ca457cbcb7528..fbe19e5802b5374417ce0a6200304520e72d1b10 100644 --- a/qa/qa/page/component/clone_panel.rb +++ b/qa/qa/page/component/clone_panel.rb @@ -24,7 +24,7 @@ module QA private def repository_clone_location(kind) - wait(reload: false) do + wait_until(reload: false) do click_element :clone_dropdown within_element :clone_options do diff --git a/qa/qa/page/component/dropdown_filter.rb b/qa/qa/page/component/dropdown_filter.rb index e896c3827793d9d8f7e8c91e0d8a8a99feafdcb1..a39a04a668db24730f1c113f09986731b3cc0439 100644 --- a/qa/qa/page/component/dropdown_filter.rb +++ b/qa/qa/page/component/dropdown_filter.rb @@ -5,9 +5,7 @@ module QA module Component module DropdownFilter def filter_and_select(item) - wait(reload: false) do - page.has_css?('.dropdown-input-field') - end + page.has_css?('.dropdown-input-field', wait: QA::Support::Repeater::DEFAULT_MAX_WAIT_TIME) find('.dropdown-input-field').set(item) click_link item diff --git a/qa/qa/page/component/dropzone.rb b/qa/qa/page/component/dropzone.rb index 757111f240b61ff401bfdb674f802ea5dd4d7c24..2efb96a02bc141072c998bd996dfd88e370a3646 100644 --- a/qa/qa/page/component/dropzone.rb +++ b/qa/qa/page/component/dropzone.rb @@ -23,7 +23,7 @@ module QA page.attach_file(attachment, class: 'dz-hidden-input', make_visible: field_style) # Wait for link to be appended to dropzone text - page.wait(reload: false) do + page.wait_until(reload: false) do page.find("#{container} textarea").value.match(filename) end end diff --git a/qa/qa/page/component/groups_filter.rb b/qa/qa/page/component/groups_filter.rb index cc50bb439b42e09077fa7ec659f7fd18c89dbc07..7eb1257db71d6d219c9c274e7e8f122779e0c6fb 100644 --- a/qa/qa/page/component/groups_filter.rb +++ b/qa/qa/page/component/groups_filter.rb @@ -23,9 +23,7 @@ module QA # Since we submitted after filtering, the presence of # groups_list_tree_container means we have the complete filtered list # of groups - wait(reload: false) do - page.has_css?(element_selector_css(:groups_list_tree_container)) - end + has_element?(:groups_list_tree_container, wait: QA::Support::Repeater::DEFAULT_MAX_WAIT_TIME) # If there are no groups we'll know immediately because we filtered the list return false if page.has_text?('No groups or projects matched your search', wait: 0) diff --git a/qa/qa/page/component/issuable/common.rb b/qa/qa/page/component/issuable/common.rb index 9ecc8f73bdbcd46c9945a31cc8b4d3f5bb1fa643..1155d4da036cc22fbb6e020e3b91b90b54c3bee2 100644 --- a/qa/qa/page/component/issuable/common.rb +++ b/qa/qa/page/component/issuable/common.rb @@ -23,11 +23,6 @@ module QA element :save_button element :delete_button end - - base.view 'app/assets/javascripts/issue_show/components/edit_actions.vue' do - element :save_button - element :delete_button - end end end end diff --git a/qa/qa/page/component/legacy_clone_panel.rb b/qa/qa/page/component/legacy_clone_panel.rb index e495cf4ef04ca1aa346f566bd5ce5c67892b4f46..7b4b30623a60236cb02e352de1b35b30acc80aac 100644 --- a/qa/qa/page/component/legacy_clone_panel.rb +++ b/qa/qa/page/component/legacy_clone_panel.rb @@ -30,7 +30,7 @@ module QA private def choose_repository_clone(kind, detect_text) - wait(reload: false) do + wait_until(reload: false) do click_element :clone_dropdown page.within('.clone-options-dropdown') do diff --git a/qa/qa/page/component/note.rb b/qa/qa/page/component/note.rb index c85fa690d6cd8e838c309c9d434ff97ff52ba952..3e8ed9069cecdb9101b9400935831d660727c77e 100644 --- a/qa/qa/page/component/note.rb +++ b/qa/qa/page/component/note.rb @@ -45,17 +45,17 @@ module QA click_element :comment_button end - def toggle_comments - all_elements(:toggle_comments_button).last.click + def toggle_comments(position) + all_elements(:toggle_comments_button, minimum: position)[position - 1].click end - def type_reply_to_discussion(reply_text) - all_elements(:discussion_reply_tab).last.click + def type_reply_to_discussion(position, reply_text) + all_elements(:discussion_reply_tab, minimum: position)[position - 1].click fill_element :reply_input, reply_text end - def reply_to_discussion(reply_text) - type_reply_to_discussion(reply_text) + def reply_to_discussion(position, reply_text) + type_reply_to_discussion(position, reply_text) click_element :reply_comment_button end diff --git a/qa/qa/page/file/shared/commit_button.rb b/qa/qa/page/file/shared/commit_button.rb index 559b4c6ceea8f73666e9ef72334fcde2b06c1c55..9ea4f4e7818a4f110e514ccc9a0123b8b258a5b2 100644 --- a/qa/qa/page/file/shared/commit_button.rb +++ b/qa/qa/page/file/shared/commit_button.rb @@ -14,7 +14,7 @@ module QA def commit_changes click_element(:commit_button) - wait(reload: false, max: 60) do + wait_until(reload: false, max_duration: 60) do finished_loading? end end diff --git a/qa/qa/page/group/settings/general.rb b/qa/qa/page/group/settings/general.rb index efc8bbd748205c81192ad394582ba38c24c3918f..4a30403fda8e1d598d8741f4daca16354006477c 100644 --- a/qa/qa/page/group/settings/general.rb +++ b/qa/qa/page/group/settings/general.rb @@ -94,6 +94,18 @@ module QA select_element(:project_creation_level_dropdown, value) click_element :save_permissions_changes_button end + + def toggle_request_access + expand_section :permission_lfs_2fa_section + + if find_element(:request_access_checkbox).checked? + uncheck_element :request_access_checkbox + else + check_element :request_access_checkbox + end + + click_element :save_permissions_changes_button + end end end end diff --git a/qa/qa/page/group/show.rb b/qa/qa/page/group/show.rb index e1f319da134fb62c53bc5cbf05960b512d03cc77..7639def98b757e04ea57d9e018c0f8302aba8d08 100644 --- a/qa/qa/page/group/show.rb +++ b/qa/qa/page/group/show.rb @@ -58,7 +58,7 @@ module QA QA::Support::Retrier.retry_on_exception(sleep_interval: 1.0) do within_element(:new_project_or_subgroup_dropdown) do # May need to click again because it is possible to click the button quicker than the JS is bound - wait(reload: false) do + wait_until(reload: false) do click_element :new_project_or_subgroup_dropdown_toggle has_element?(kind) diff --git a/qa/qa/page/group/sub_menus/members.rb b/qa/qa/page/group/sub_menus/members.rb index c8b3f5bb42204ee45e1d97947311ac33a3af8c63..33c4caaddcb21cdabf4e8d7a8918296cf81eddbb 100644 --- a/qa/qa/page/group/sub_menus/members.rb +++ b/qa/qa/page/group/sub_menus/members.rb @@ -7,12 +7,9 @@ module QA class Members < Page::Base include Page::Component::UsersSelect - view 'app/views/groups/group_members/_new_group_member.html.haml' do - element :add_to_group_button - end - - view 'app/helpers/groups/group_members_helper.rb' do + view 'app/views/shared/members/_invite_member.html.haml' do element :member_select_field + element :invite_member_button end view 'app/views/shared/members/_member.html.haml' do @@ -24,7 +21,7 @@ module QA def add_member(username) select_user :member_select_field, username - click_element :add_to_group_button + click_element :invite_member_button end def update_access_level(username, access_level) diff --git a/qa/qa/page/layout/performance_bar.rb b/qa/qa/page/layout/performance_bar.rb index 79e4d3edce09c86866acfd56a26045ef2eb168e3..4e144e67f12f9f3a31a79c32499aa81feed570e4 100644 --- a/qa/qa/page/layout/performance_bar.rb +++ b/qa/qa/page/layout/performance_bar.rb @@ -9,25 +9,31 @@ module QA end view 'app/assets/javascripts/performance_bar/components/detailed_metric.vue' do - element :performance_bar_detailed_metric + element :detailed_metric_content end view 'app/assets/javascripts/performance_bar/components/request_selector.vue' do - element :performance_bar_request + element :request_dropdown_option + element :request_dropdown end def has_performance_bar? has_element?(:performance_bar) end - def has_detailed_metrics? - all_elements(:performance_bar_detailed_metric).all? do |metric| - metric.has_text?(%r{\d+}) + def has_detailed_metrics?(count) + retry_until(sleep_interval: 1) do + all_elements(:detailed_metric_content, count: count).all? do |metric| + metric.has_text?(%r{\d+}) + end end end def has_request_for?(path) - has_element?(:performance_bar_request, text: path) + click_element(:request_dropdown) + retry_until(sleep_interval: 1) do + has_element?(:request_dropdown_option, text: path) + end end end end diff --git a/qa/qa/page/main/menu.rb b/qa/qa/page/main/menu.rb index 5f4b3946e6a65ec00c665090ffc99c7cffe85151..8ad30632fa12de607f090205dc77e28f88752538 100644 --- a/qa/qa/page/main/menu.rb +++ b/qa/qa/page/main/menu.rb @@ -76,8 +76,14 @@ module QA end def sign_out - within_user_menu do - click_element :sign_out_link + retry_until do + break true unless signed_in? + + within_user_menu do + click_element :sign_out_link + end + + has_no_element?(:user_avatar) end end diff --git a/qa/qa/page/merge_request/show.rb b/qa/qa/page/merge_request/show.rb index c3645a6a7554a661157b8d9af2c306f765b76159..ad5b3c97cb9f4af7ecf2be51c2174528f2934f59 100644 --- a/qa/qa/page/merge_request/show.rb +++ b/qa/qa/page/merge_request/show.rb @@ -6,6 +6,10 @@ module QA class Show < Page::Base include Page::Component::Note + view 'app/assets/javascripts/mr_tabs_popover/components/popover.vue' do + element :dismiss_popover_button + end + view 'app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue' do element :dropdown_toggle element :download_email_patches @@ -29,6 +33,10 @@ module QA element :merged_status_content end + view 'app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue' do + element :merge_request_error_content + end + view 'app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue' do element :mr_rebase_button element :no_fast_forward_message, 'Fast-forward merge is not possible' # rubocop:disable QA/ElementWithPattern @@ -38,6 +46,10 @@ module QA element :squash_checkbox end + view 'app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue' do + element :skeleton_note + end + view 'app/views/projects/merge_requests/show.html.haml' do element :notes_tab element :diffs_tab @@ -61,32 +73,34 @@ module QA end def add_comment_to_diff(text) - wait(interval: 5) do + wait_until(sleep_interval: 5) do has_text?("No newline at end of file") end - all_elements(:new_diff_line).first.hover - click_element :diff_comment - fill_element :reply_input, text + all_elements(:new_diff_line, minimum: 1).first.hover + click_element(:diff_comment) + fill_element(:reply_input, text) end def click_discussions_tab - click_element :notes_tab + click_element(:notes_tab) - finished_loading? + wait_for_loading end def click_diffs_tab - click_element :diffs_tab + click_element(:diffs_tab) - finished_loading? + wait_for_loading + + click_element(:dismiss_popover_button) if has_element?(:dismiss_popover_button) end def click_pipeline_link - click_element :pipeline_link + click_element(:pipeline_link) end def edit! - click_element :edit_button + click_element(:edit_button) end def fast_forward_possible? @@ -126,12 +140,12 @@ module QA def mark_to_squash # The squash checkbox is disabled on load - wait do + wait_until do has_element?(:squash_checkbox) end # The squash checkbox is enabled via JS - wait(reload: false) do + wait_until(reload: false) do !find_element(:squash_checkbox).disabled? end @@ -150,30 +164,30 @@ module QA def ready_to_merge? # The merge button is disabled on load - wait do + wait_until do has_element?(:merge_button) end # The merge button is enabled via JS - wait(reload: false) do + wait_until(reload: false) do !find_element(:merge_button).disabled? end end def rebase! # The rebase button is disabled on load - wait do + wait_until do has_element?(:mr_rebase_button) end # The rebase button is enabled via JS - wait(reload: false) do + wait_until(reload: false) do !find_element(:mr_rebase_button).disabled? end click_element :mr_rebase_button - success = wait do + success = wait_until do has_text?('Fast-forward merge without a merge commit') end @@ -193,6 +207,16 @@ module QA click_element :dropdown_toggle visit_link_in_element(:download_plain_diff) end + + def wait_for_merge_request_error_message + wait_until(max_duration: 30, reload: false) do + has_element?(:merge_request_error_content) + end + end + + def wait_for_loading + finished_loading? && has_no_element?(:skeleton_note) + end end end end diff --git a/qa/qa/page/project/branches/show.rb b/qa/qa/page/project/branches/show.rb index 480fc7d78cbe28d2dbc971a2cb30e457efeb2748..63021df30f6656bdc2e5b3a1803f9838b4d7044e 100644 --- a/qa/qa/page/project/branches/show.rb +++ b/qa/qa/page/project/branches/show.rb @@ -29,7 +29,7 @@ module QA end def has_no_branch?(branch_name, reload: false) - wait(reload: reload) do + wait_until(reload: reload) do within_element(:all_branches) do has_no_element?(:branch_name, text: branch_name) end diff --git a/qa/qa/page/project/import/github.rb b/qa/qa/page/project/import/github.rb index cc0c4e1e8357505fa58859c578a54c795c2a571c..b533e0096a89d0e936cfc9ad0ab69b2b02f6ec4a 100644 --- a/qa/qa/page/project/import/github.rb +++ b/qa/qa/page/project/import/github.rb @@ -8,8 +8,8 @@ module QA include Page::Component::Select2 view 'app/views/import/github/new.html.haml' do - element :personal_access_token_field, 'text_field_tag :personal_access_token' # rubocop:disable QA/ElementWithPattern - element :authenticate_button, "submit_tag _('Authenticate')" # rubocop:disable QA/ElementWithPattern + element :personal_access_token_field + element :authenticate_button end view 'app/assets/javascripts/import_projects/components/provider_repo_table_row.vue' do @@ -20,11 +20,9 @@ module QA end def add_personal_access_token(personal_access_token) - fill_in 'personal_access_token', with: personal_access_token - end - - def list_repos - click_button 'List your GitHub repositories' + fill_element(:personal_access_token_field, personal_access_token) + click_element(:authenticate_button) + finished_loading? end def import!(full_path, name) @@ -37,7 +35,7 @@ module QA private def within_repo_path(full_path) - wait(reload: false) do + wait_until(reload: false) do has_element?(:project_import_row, text: full_path) end @@ -69,7 +67,7 @@ module QA end def wait_for_success - wait(max: 60, interval: 1.0, reload: false) do + wait_until(max_duration: 60, sleep_interval: 1.0, reload: false) do page.has_content?('Done', wait: 1.0) end end diff --git a/qa/qa/page/project/issue/index.rb b/qa/qa/page/project/issue/index.rb index a6ccee4353bcbb41b5e436e1b1191335b8ceb35c..b5ad63ab8debd8bed0008850aa3bad79ba5e6d1c 100644 --- a/qa/qa/page/project/issue/index.rb +++ b/qa/qa/page/project/issue/index.rb @@ -10,6 +10,7 @@ module QA end view 'app/views/projects/issues/_issue.html.haml' do + element :issue element :issue_link, 'link_to issue.title' # rubocop:disable QA/ElementWithPattern end @@ -21,10 +22,6 @@ module QA element :closed_issues_link end - def assignee_link_count - all_elements(:assignee_link).count - end - def avatar_counter find_element(:avatar_counter) end @@ -37,8 +34,12 @@ module QA click_element :closed_issues_link end + def has_assignee_link_count?(count) + all_elements(:assignee_link, count: count) + end + def has_issue?(issue) - has_element? :issue, issue_title: issue.to_s + has_element? :issue, issue_title: issue.title end end end diff --git a/qa/qa/page/project/issue/show.rb b/qa/qa/page/project/issue/show.rb index 1ef711d459e1d103308d19486c84605cb921e592..a1e1bb4bc987da2fcb20043e8f30a82d9933cd6b 100644 --- a/qa/qa/page/project/issue/show.rb +++ b/qa/qa/page/project/issue/show.rb @@ -56,12 +56,6 @@ module QA element :new_note_form, 'attr: :note' # rubocop:disable QA/ElementWithPattern end - def avatar_image_count - wait_assignees_block_finish_loading do - all_elements(:avatar_image).count - end - end - def click_milestone_link click_element(:milestone_link) end @@ -88,12 +82,16 @@ module QA click_element :comment_button end - def has_comment?(comment_text) - wait(reload: false) do - has_element?(:noteable_note_item, text: comment_text) + def has_avatar_image_count?(count) + wait_assignees_block_finish_loading do + all_elements(:avatar_image, count: count) end end + def has_comment?(comment_text) + has_element?(:noteable_note_item, text: comment_text, wait: QA::Support::Repeater::DEFAULT_MAX_WAIT_TIME) + end + def more_assignees_link find_element(:more_assignees_link) end @@ -155,7 +153,7 @@ module QA def wait_assignees_block_finish_loading within_element(:assignee_block) do - wait(reload: false, max: 10, interval: 1) do + wait_until(reload: false, max_duration: 10, sleep_interval: 1) do finished_loading_block? yield end diff --git a/qa/qa/page/project/job/show.rb b/qa/qa/page/project/job/show.rb index a1a5b3c296ef385d89a83a56b02692745c6141d1..07dea3449f107dec4203aa5a571891a941e6f2e4 100644 --- a/qa/qa/page/project/job/show.rb +++ b/qa/qa/page/project/job/show.rb @@ -5,8 +5,8 @@ module QA::Page class Show < QA::Page::Base include Component::CiBadgeLink - view 'app/assets/javascripts/jobs/components/job_log.vue' do - element :build_trace + view 'app/assets/javascripts/jobs/components/log/log.vue' do + element :job_log_content end view 'app/assets/javascripts/jobs/components/stages_dropdown.vue' do @@ -24,8 +24,8 @@ module QA::Page def output(wait: 5) result = '' - wait(reload: false, max: wait, interval: 1) do - result = find_element(:build_trace).text + wait_until(reload: false, max_duration: wait, sleep_interval: 1) do + result = find_element(:job_log_content).text result.include?('Job') end @@ -36,8 +36,8 @@ module QA::Page private def loaded?(wait: 60) - wait(reload: true, max: wait, interval: 1) do - has_element?(:build_trace, wait: 1) + wait_until(reload: true, max_duration: wait, sleep_interval: 1) do + has_element?(:job_log_content, wait: 1) end end end diff --git a/qa/qa/page/project/operations/environments/index.rb b/qa/qa/page/project/operations/environments/index.rb index 610a34385b1fe1e6320a290a077fba35baf40159..6b46fa4985ab36b932cfa18cb24c2f2d67e17070 100644 --- a/qa/qa/page/project/operations/environments/index.rb +++ b/qa/qa/page/project/operations/environments/index.rb @@ -11,9 +11,7 @@ module QA end def click_environment_link(environment_name) - wait(reload: false) do - find(element_selector_css(:environment_link), text: environment_name).click - end + click_element(:environment_link, text: environment_name) end end end diff --git a/qa/qa/page/project/operations/kubernetes/index.rb b/qa/qa/page/project/operations/kubernetes/index.rb index de54319596da0663b248c44654cf9a161963acd5..84b58e9ea5b9f3eecde98a8537f783db04465d63 100644 --- a/qa/qa/page/project/operations/kubernetes/index.rb +++ b/qa/qa/page/project/operations/kubernetes/index.rb @@ -13,6 +13,10 @@ module QA def add_kubernetes_cluster click_on 'Add Kubernetes cluster' end + + def has_cluster?(cluster) + has_element?(:cluster, cluster_name: cluster.to_s) + end end end end diff --git a/qa/qa/page/project/operations/kubernetes/show.rb b/qa/qa/page/project/operations/kubernetes/show.rb index fa276f15b8a355a93102a073303943adff346bbd..3d3eebdbec9dcfeb07b68efa7a7dc440f7d47ef7 100644 --- a/qa/qa/page/project/operations/kubernetes/show.rb +++ b/qa/qa/page/project/operations/kubernetes/show.rb @@ -6,12 +6,6 @@ module QA module Operations module Kubernetes class Show < Page::Base - view 'app/assets/javascripts/clusters/components/application_row.vue' do - element :application_row, 'js-cluster-application-row-${this.id}' # rubocop:disable QA/ElementWithPattern - element :install_button, "__('Install')" # rubocop:disable QA/ElementWithPattern - element :installed_button, "__('Installed')" # rubocop:disable QA/ElementWithPattern - end - view 'app/assets/javascripts/clusters/components/applications.vue' do element :ingress_ip_address, 'id="ingress-endpoint"' # rubocop:disable QA/ElementWithPattern end @@ -22,15 +16,21 @@ module QA end def install!(application_name) - within(".js-cluster-application-row-#{application_name}") do - page.has_button?('Install', wait: 30) - click_on 'Install' + within_element(application_name) do + has_element?(:install_button, application: application_name, wait: 30) + click_on 'Install' # TODO replace with click_element end end def await_installed(application_name) - within(".js-cluster-application-row-#{application_name}") do - page.has_text?(/Installed|Uninstall/, wait: 300) + within_element(application_name) do + has_element?(:uninstall_button, application: application_name, wait: 300) + end + end + + def has_application_installed?(application_name) + within_element(application_name) do + has_element?(:uninstall_button, application: application_name, wait: 300) end end diff --git a/qa/qa/page/project/pipeline/index.rb b/qa/qa/page/project/pipeline/index.rb index 269d4dfc411fb0fdde9f55953e03210370db7019..684ad4a59d5b94d8edfe922b945d17d55baa0a3c 100644 --- a/qa/qa/page/project/pipeline/index.rb +++ b/qa/qa/page/project/pipeline/index.rb @@ -18,7 +18,7 @@ module QA::Page end def wait_for_latest_pipeline_success - wait(reload: false, max: 300) do + wait_until(reload: false, max_duration: 300) do within_element_by_index(:pipeline_commit_status, 0) do has_text?('passed') end diff --git a/qa/qa/page/project/pipeline/show.rb b/qa/qa/page/project/pipeline/show.rb index fd29c5eacdcfca5b55abca929b975dd925993648..45fffbf60009ef0bfd81779dc97ad9547091ec4b 100644 --- a/qa/qa/page/project/pipeline/show.rb +++ b/qa/qa/page/project/pipeline/show.rb @@ -67,13 +67,7 @@ module QA::Page end def click_on_first_job - css = '.js-pipeline-graph-job-link' - - wait(reload: false) do - has_css?(css) - end - - first(css).click + first('.js-pipeline-graph-job-link', wait: QA::Support::Repeater::DEFAULT_MAX_WAIT_TIME).click end end end diff --git a/qa/qa/page/project/settings/ci_variables.rb b/qa/qa/page/project/settings/ci_variables.rb index 3621e618bf2a05376484843f1366b784bd69a237..64a182e5b3a152378956efd6fa1d23f5440fc45d 100644 --- a/qa/qa/page/project/settings/ci_variables.rb +++ b/qa/qa/page/project/settings/ci_variables.rb @@ -20,13 +20,13 @@ module QA end def fill_variable(key, value, masked) - keys = all_elements(:ci_variable_input_key) + keys = all_elements(:ci_variable_input_key, minimum: 1) index = keys.size - 1 # After we fill the key, JS would generate another field so # we need to use the same index to find the corresponding one. keys[index].set(key) - node = all_elements(:ci_variable_input_value)[index] + node = all_elements(:ci_variable_input_value, count: keys.size + 1)[index] # Simply run `node.set(value)` is too slow for long text here, # so we need to run JavaScript directly to set the value. @@ -34,7 +34,7 @@ module QA # https://github.com/teamcapybara/capybara/blob/679548cea10773d45e32808f4d964377cfe5e892/lib/capybara/selenium/node.rb#L217 execute_script("arguments[0].value = #{value.to_json}", node) - masked_node = all_elements(:variable_masked)[index] + masked_node = all_elements(:variable_masked, count: keys.size + 1)[index] toggle_masked(masked_node, masked) end @@ -55,7 +55,7 @@ module QA private def toggle_masked(masked_node, masked) - wait(reload: false) do + wait_until(reload: false) do masked_node.click masked ? masked_enabled?(masked_node) : masked_disabled?(masked_node) diff --git a/qa/qa/page/project/settings/deploy_keys.rb b/qa/qa/page/project/settings/deploy_keys.rb index 602bfc647104bed136e3a6a8ffb372791ae6a379..c330d090ce6ec42b15ef23e6c5eb52664b9497e6 100644 --- a/qa/qa/page/project/settings/deploy_keys.rb +++ b/qa/qa/page/project/settings/deploy_keys.rb @@ -18,7 +18,7 @@ module QA view 'app/assets/javascripts/deploy_keys/components/key.vue' do element :key element :key_title - element :key_fingerprint + element :key_md5_fingerprint end def add_key @@ -33,17 +33,17 @@ module QA fill_in 'deploy_key_key', with: key end - def find_fingerprint(title) + def find_md5_fingerprint(title) within_project_deploy_keys do find_element(:key, text: title) - .find(element_selector_css(:key_fingerprint)).text + .find(element_selector_css(:key_md5_fingerprint)).text.delete_prefix('MD5:') end end - def has_key?(title, fingerprint) + def has_key?(title, md5_fingerprint) within_project_deploy_keys do find_element(:key, text: title) - .has_css?(element_selector_css(:key_fingerprint), text: fingerprint) + .has_css?(element_selector_css(:key_md5_fingerprint), text: "MD5:#{md5_fingerprint}") end end @@ -53,18 +53,10 @@ module QA end end - def key_fingerprint - within_project_deploy_keys do - find_element(:key_fingerprint).text - end - end - private def within_project_deploy_keys - wait(reload: false) do - has_element?(:project_deploy_keys) - end + has_element?(:project_deploy_keys, wait: QA::Support::Repeater::DEFAULT_MAX_WAIT_TIME) within_element(:project_deploy_keys) do yield diff --git a/qa/qa/page/project/settings/deploy_tokens.rb b/qa/qa/page/project/settings/deploy_tokens.rb index ad34ebc13c231d378a303d754c7be7b379a0a250..3173752d40acec05675575594444ed54155bd505 100644 --- a/qa/qa/page/project/settings/deploy_tokens.rb +++ b/qa/qa/page/project/settings/deploy_tokens.rb @@ -51,9 +51,7 @@ module QA private def within_new_project_deploy_token - wait(reload: false) do - has_css?(element_selector_css(:created_deploy_token_section)) - end + has_element?(:created_deploy_token_section, wait: QA::Support::Repeater::DEFAULT_MAX_WAIT_TIME) within_element(:created_deploy_token_section) do yield diff --git a/qa/qa/page/project/settings/members.rb b/qa/qa/page/project/settings/members.rb index 2ef018fd983afb76e484d28b5c22d2a44066f31d..fd3e0add2a668bc0642e60d1fab41b93f750c4c4 100644 --- a/qa/qa/page/project/settings/members.rb +++ b/qa/qa/page/project/settings/members.rb @@ -8,9 +8,9 @@ module QA include Page::Component::UsersSelect include QA::Page::Component::Select2 - view 'app/views/projects/project_members/_new_project_member.html.haml' do - element :member_select_input - element :add_member_button + view 'app/views/shared/members/_invite_member.html.haml' do + element :member_select_field + element :invite_member_button end view 'app/views/projects/project_members/_team.html.haml' do @@ -21,7 +21,7 @@ module QA element :invite_group_tab end - view 'app/views/projects/project_members/_new_project_group.html.haml' do + view 'app/views/shared/members/_invite_group.html.haml' do element :group_select_field element :invite_group_button end @@ -43,8 +43,8 @@ module QA end def add_member(username) - select_user :member_select_input, username - click_element :add_member_button + select_user :member_select_field, username + click_element :invite_member_button end def remove_group(group_name) diff --git a/qa/qa/page/project/settings/mirroring_repositories.rb b/qa/qa/page/project/settings/mirroring_repositories.rb index 4afe042d9fb9aa5d5169204f183b64f770bb31c4..517163a22f10b0b32b535e4ef66ea11ad59311e0 100644 --- a/qa/qa/page/project/settings/mirroring_repositories.rb +++ b/qa/qa/page/project/settings/mirroring_repositories.rb @@ -77,9 +77,7 @@ module QA # The host key detection process is interrupted if we navigate away # from the page before the fingerprint appears. - wait(max: 5) do - find_element(:fingerprints_list).has_text? /.*/ - end + find_element(:fingerprints_list, text: /.*/) end def mirror_repository @@ -100,7 +98,7 @@ module QA sleep 5 refresh - wait(interval: 1) do + wait_until(sleep_interval: 1) do within_element_by_index(:mirrored_repository_row, row_index) do last_update = find_element(:mirror_last_update_at_cell, wait: 0) last_update.has_text?('just now') || last_update.has_text?('seconds') @@ -117,8 +115,8 @@ module QA private def find_repository_row_index(target_url) - wait(max: 5, reload: false) do - all_elements(:mirror_repository_url_cell).index do |url| + wait_until(max_duration: 5, reload: false) do + all_elements(:mirror_repository_url_cell, minimum: 1).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 diff --git a/qa/qa/page/project/settings/protected_branches.rb b/qa/qa/page/project/settings/protected_branches.rb index d1d2f302013ffa12789624ebfb1b8e7c936b473f..f718311fbf2e15a39bfdc64459da54a2496a1dd7 100644 --- a/qa/qa/page/project/settings/protected_branches.rb +++ b/qa/qa/page/project/settings/protected_branches.rb @@ -46,7 +46,7 @@ module QA end def protect_branch - click_element :protect_button + click_element(:protect_button, wait: QA::Support::Repeater::DEFAULT_MAX_WAIT_TIME) end private @@ -58,10 +58,9 @@ module QA within_element(:"allowed_to_#{action}_dropdown") do click_on allowed[:roles] + allowed[:users].each { |user| click_on user.username } if allowed.key?(:users) + allowed[:groups].each { |group| click_on group.name } if allowed.key?(:groups) end - - # Click the select element again to close the dropdown - click_element :protected_branch_select end end end diff --git a/qa/qa/page/project/show.rb b/qa/qa/page/project/show.rb index 102b6144a1e3ac5c37b260509ba5067e0a9ea9c9..c619bd6d6a36e33dc1b80a9ea092c0d04ccfafe1 100644 --- a/qa/qa/page/project/show.rb +++ b/qa/qa/page/project/show.rb @@ -61,9 +61,7 @@ module QA end def wait_for_viewers_to_load - wait(reload: false) do - has_no_element?(:spinner) - end + has_no_element?(:spinner, wait: QA::Support::Repeater::DEFAULT_MAX_WAIT_TIME) end def create_first_new_file! @@ -103,7 +101,7 @@ module QA end def new_merge_request - wait(reload: true) do + wait_until(reload: true) do has_css?(element_selector_css(:create_merge_request)) end @@ -127,7 +125,7 @@ module QA end def wait_for_import - wait(reload: true) do + wait_until(reload: true) do has_css?('.tree-holder') end end diff --git a/qa/qa/page/project/web_ide/edit.rb b/qa/qa/page/project/web_ide/edit.rb index 4d26cadcdfef6b06fd4bf0f79b8af0bff64e5feb..73b0856b44525174c67c131d4639580f08461fd9 100644 --- a/qa/qa/page/project/web_ide/edit.rb +++ b/qa/qa/page/project/web_ide/edit.rb @@ -69,7 +69,7 @@ module QA # Wait for the modal to fade out too has_no_element?(:new_file_modal) - wait(reload: false) do + wait_until(reload: false) do within_element(:file_templates_bar) do click_element :file_template_dropdown fill_element :dropdown_filter_input, template @@ -84,35 +84,29 @@ module QA end def commit_changes - # Clicking :begin_commit_button the first time switches from the + # Clicking :begin_commit_button switches from the # edit to the commit view click_element :begin_commit_button active_element? :commit_mode_tab - # We need to click :begin_commit_button again - click_element :begin_commit_button - - # After clicking :begin_commit_button the 2nd time there is an - # animation that hides :begin_commit_button and shows :commit_button + # After clicking :begin_commit_button, there is an animation + # that hides :begin_commit_button and shows :commit_button # # Wait for the animation to complete before clicking :commit_button # otherwise the click will quietly do nothing. - wait(reload: false) do + wait_until(reload: false) do has_no_element?(:begin_commit_button) && has_element?(:commit_button) end - # At this point we're ready to commit and the button should be - # labelled "Stage & Commit" - # # Click :commit_button and keep retrying just in case part of the # animation is still in process even when the buttons have the # expected visibility. - commit_success_msg_shown = retry_until do - click_element :commit_to_current_branch_radio - click_element :commit_button + commit_success_msg_shown = retry_until(sleep_interval: 5) do + click_element(:commit_to_current_branch_radio) if has_element?(:commit_to_current_branch_radio) + click_element(:commit_button) if has_element?(:commit_button) - wait(reload: false) do + wait_until(reload: false) do has_text?('Your changes have been committed') end end diff --git a/qa/qa/page/search/results.rb b/qa/qa/page/search/results.rb index 2f99d8da784947bf3987914e7724925d114df8f7..85f1d2249354f21b4191c22e985d94d1d39a5040 100644 --- a/qa/qa/page/search/results.rb +++ b/qa/qa/page/search/results.rb @@ -19,11 +19,11 @@ module QA::Page end def switch_to_code - click_element(:code_tab) + switch_to_tab(:code_tab) end def switch_to_projects - click_element(:projects_tab) + switch_to_tab(:projects_tab) end def has_file_in_project?(file_name, project_name) @@ -32,7 +32,7 @@ module QA::Page def has_file_with_content?(file_name, file_text) within_element_by_index(:result_item_content, 0) do - false unless has_element?(:file_title_content, text: file_name) + break false unless has_element?(:file_title_content, text: file_name) has_element?(:file_text_content, text: file_text) end @@ -41,6 +41,15 @@ module QA::Page def has_project?(project_name) has_element?(:project, project_name: project_name) end + + private + + def switch_to_tab(tab) + retry_until do + click_element(tab) + has_active_element?(tab) + end + end end end end diff --git a/qa/qa/page/settings/common.rb b/qa/qa/page/settings/common.rb index bd1070158f06ec47b211dad1577a2cf03b4569a8..6989e8125d3f4f83998bca36d9d9abbf82e1869d 100644 --- a/qa/qa/page/settings/common.rb +++ b/qa/qa/page/settings/common.rb @@ -10,7 +10,7 @@ module QA def expand_section(element_name) within_element(element_name) do # Because it is possible to click the button before the JS toggle code is bound - wait(reload: false) do + wait_until(reload: false) do click_button 'Expand' unless has_css?('button', text: 'Collapse', wait: 1) has_content?('Collapse') diff --git a/qa/qa/page/validatable.rb b/qa/qa/page/validatable.rb index 8467d261285a0d50f27920769d7fb641cdb678be..3c4d9ad68aa7c695ff29300050a42d6f211e8db3 100644 --- a/qa/qa/page/validatable.rb +++ b/qa/qa/page/validatable.rb @@ -11,8 +11,7 @@ module QA elements.each do |element| next unless element.required? - # TODO: this wait needs to be replaced by the wait class - unless base_page.has_element?(element.name, wait: 60) + unless base_page.has_element?(element.name, wait: QA::Support::Repeater::DEFAULT_MAX_WAIT_TIME) raise Validatable::PageValidationError, "#{element.name} did not appear on #{self.name} as expected" end end diff --git a/qa/qa/resource/base.rb b/qa/qa/resource/base.rb index 3bb627032909c3dbc56c82bf6e2fadd3586e1a54..873ba353051110f7971258c44db61ec49e4b1e25 100644 --- a/qa/qa/resource/base.rb +++ b/qa/qa/resource/base.rb @@ -66,18 +66,24 @@ module QA def visit! Runtime::Logger.debug(%Q[Visiting #{self.class.name} at "#{web_url}"]) + # Just in case an async action is not yet complete + Support::WaitForRequests.wait_for_requests + Support::Retrier.retry_until do visit(web_url) - wait { current_url.include?(URI.parse(web_url).path.split('/').last || web_url) } + wait_until { current_url.include?(URI.parse(web_url).path.split('/').last || web_url) } end + + # Wait until the new page is ready for us to interact with it + Support::WaitForRequests.wait_for_requests end def populate(*attributes) attributes.each(&method(:public_send)) end - def wait(max: 60, interval: 0.1) - QA::Support::Waiter.wait(max: max, interval: interval) do + def wait_until(max_duration: 60, sleep_interval: 0.1) + QA::Support::Waiter.wait_until(max_duration: max_duration, sleep_interval: sleep_interval) do yield end end diff --git a/qa/qa/resource/deploy_key.rb b/qa/qa/resource/deploy_key.rb index 869e2a71e477cc1331fadc45a815511a4af1ab73..26355729dab665cb2626196dbafe5e7e2d8a66fd 100644 --- a/qa/qa/resource/deploy_key.rb +++ b/qa/qa/resource/deploy_key.rb @@ -5,10 +5,10 @@ module QA class DeployKey < Base attr_accessor :title, :key - attribute :fingerprint do + attribute :md5_fingerprint do Page::Project::Settings::Repository.perform do |setting| setting.expand_deploy_keys do |key| - key.find_fingerprint(title) + key.find_md5_fingerprint(title) end end end diff --git a/qa/qa/resource/events/base.rb b/qa/qa/resource/events/base.rb index b50b620b143e1d8228c1dd9a68aed52020aae06b..f98a54a6f571926c52cfe51406a7d63f8eb01031 100644 --- a/qa/qa/resource/events/base.rb +++ b/qa/qa/resource/events/base.rb @@ -4,6 +4,7 @@ module QA module Resource module Events MAX_WAIT = 10 + RAISE_ON_FAILURE = true EventNotFoundError = Class.new(RuntimeError) @@ -21,7 +22,7 @@ module QA end def wait_for_event - event_found = QA::Support::Waiter.wait(max: max_wait) do + event_found = Support::Waiter.wait_until(max_duration: max_wait, raise_on_failure: raise_on_failure) do yield end @@ -31,6 +32,10 @@ module QA def max_wait MAX_WAIT end + + def raise_on_failure + RAISE_ON_FAILURE + end end end end diff --git a/qa/qa/resource/group.rb b/qa/qa/resource/group.rb index c12e9dd146b792a66edca3277d9a9169db6b32c6..0824512d238dd28bf25edca6f2da77b717839419 100644 --- a/qa/qa/resource/group.rb +++ b/qa/qa/resource/group.rb @@ -39,7 +39,7 @@ module QA end # Ensure that the group was actually created - group_show.wait(interval: 1) do + group_show.wait_until(sleep_interval: 1) do group_show.has_text?(path) && group_show.has_new_project_or_subgroup_dropdown? end diff --git a/qa/qa/resource/issue.rb b/qa/qa/resource/issue.rb index 3bcff6a10ac2acee7e8c96442a892f92dbc972cb..0817a9de06f2d23b752bc0b5eb78cb3339fc209e 100644 --- a/qa/qa/resource/issue.rb +++ b/qa/qa/resource/issue.rb @@ -38,10 +38,6 @@ module QA end end - def to_s - @title - end - def api_get_path "/projects/#{project.id}/issues/#{id}" end diff --git a/qa/qa/resource/merge_request.rb b/qa/qa/resource/merge_request.rb index 24fb96a20a2e45f9e836ac0903c6c1739f3b03c2..6c0f4621dd986c25e3b1f4a17e7c8ca237562c87 100644 --- a/qa/qa/resource/merge_request.rb +++ b/qa/qa/resource/merge_request.rb @@ -54,7 +54,7 @@ module QA @assignee = nil @milestone = nil @labels = [] - @file_name = "added_file.txt" + @file_name = "added_file-#{SecureRandom.hex(8)}.txt" @file_content = "File Added" @target_new_branch = true @no_preparation = false diff --git a/qa/qa/resource/merge_request_from_fork.rb b/qa/qa/resource/merge_request_from_fork.rb index 6c9a096289bb57f10bc0565d31e7f663ee1e663e..9cb4e6a49ca3653a3cf9c5e9aa466d081538bebe 100644 --- a/qa/qa/resource/merge_request_from_fork.rb +++ b/qa/qa/resource/merge_request_from_fork.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require 'securerandom' + module QA module Resource class MergeRequestFromFork < MergeRequest @@ -13,7 +15,7 @@ module QA Repository::ProjectPush.fabricate! do |resource| resource.project = fork.project resource.branch_name = fork_branch - resource.file_name = 'file2.txt' + resource.file_name = "file2-#{SecureRandom.hex(8)}.txt" resource.user = fork.user end end diff --git a/qa/qa/resource/project_imported_from_github.rb b/qa/qa/resource/project_imported_from_github.rb index 3e25235e6b8f9b065bc991600d8e205500856b58..e5ecaeae139a8ac3b5349c6850726f7dd9f0aa83 100644 --- a/qa/qa/resource/project_imported_from_github.rb +++ b/qa/qa/resource/project_imported_from_github.rb @@ -23,7 +23,6 @@ module QA Page::Project::Import::Github.perform do |import_page| import_page.add_personal_access_token(@personal_access_token) - import_page.list_repos import_page.import!(@github_repository_path, @name) end end diff --git a/qa/qa/resource/protected_branch.rb b/qa/qa/resource/protected_branch.rb index f0cef624e0b4833ae468db0953102799b3cf7918..9c65e0e5a31c9986ff6e31940cf6c15d02e1e086 100644 --- a/qa/qa/resource/protected_branch.rb +++ b/qa/qa/resource/protected_branch.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require 'securerandom' + module QA module Resource class ProtectedBranch < Base @@ -15,7 +17,7 @@ module QA attribute :branch do Repository::ProjectPush.fabricate! do |project_push| project_push.project = project - project_push.file_name = 'new_file.md' + project_push.file_name = "new_file-#{SecureRandom.hex(8)}.md" project_push.commit_message = 'Add new file' project_push.branch_name = branch_name project_push.new_branch = true @@ -47,11 +49,6 @@ module QA page.select_branch(branch_name) page.select_allowed_to_merge(allowed_to_merge) page.select_allowed_to_push(allowed_to_push) - - page.wait(reload: false) do - !page.first('.btn-success').disabled? - end - page.protect_branch end end diff --git a/qa/qa/resource/repository/commit.rb b/qa/qa/resource/repository/commit.rb index 4b5e8535ade7cb44372a451a1d7ba9ff07f82c79..e3fb5bf486d0826cb3276cdea45699d6aa3bb20e 100644 --- a/qa/qa/resource/repository/commit.rb +++ b/qa/qa/resource/repository/commit.rb @@ -11,6 +11,8 @@ module QA :file_path, :sha + attribute :short_id + attribute :project do Project.fabricate! do |resource| resource.name = 'project-with-commit' diff --git a/qa/qa/resource/repository/project_push.rb b/qa/qa/resource/repository/project_push.rb index f79bb035c469acb9d5163faf3569adc1fb2258c5..17596601cf9f25834ff1c41f332125892f6a3f2d 100644 --- a/qa/qa/resource/repository/project_push.rb +++ b/qa/qa/resource/repository/project_push.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require 'securerandom' + module QA module Resource module Repository @@ -15,7 +17,7 @@ module QA end def initialize - @file_name = 'file.txt' + @file_name = "file-#{SecureRandom.hex(8)}.txt" @file_content = '# This is test project' @commit_message = "This is a test commit" @branch_name = 'master' diff --git a/qa/qa/resource/repository/push.rb b/qa/qa/resource/repository/push.rb index 68674248be25229915212baedb896114d6e81a9e..902ae9f31359c16c4b3218742ec9887770bbafc0 100644 --- a/qa/qa/resource/repository/push.rb +++ b/qa/qa/resource/repository/push.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'pathname' +require 'securerandom' module QA module Resource @@ -13,7 +14,7 @@ module QA attr_writer :remote_branch, :gpg_key_id def initialize - @file_name = 'file.txt' + @file_name = "file-#{SecureRandom.hex(8)}.txt" @file_content = '# This is test file' @commit_message = "This is a test commit" @branch_name = 'master' diff --git a/qa/qa/resource/runner.rb b/qa/qa/resource/runner.rb index d1b4e8f7d54ac6bbfadcc52269ab6eb6c1d456fc..f1f72c9cacd8f791149e963918912bed8beb98a8 100644 --- a/qa/qa/resource/runner.rb +++ b/qa/qa/resource/runner.rb @@ -54,7 +54,7 @@ module QA @id = this_runner[:id] super - + ensure Service::DockerRun::GitlabRunner.new(name).remove! end diff --git a/qa/qa/resource/sandbox.rb b/qa/qa/resource/sandbox.rb index 6c87fcb377a272d5be5c39857aeff7ea636b64c3..54c13071cefea93219e99eb37cdf01cc2e0b6ca4 100644 --- a/qa/qa/resource/sandbox.rb +++ b/qa/qa/resource/sandbox.rb @@ -41,6 +41,14 @@ module QA resource_web_url(api_get) rescue ResourceNotFoundError super + + # If the group was just created the runners token might not be + # available via the API immediately. + Support::Retrier.retry_on_exception(sleep_interval: 5) do + resource = resource_web_url(api_get) + populate(:runners_token) + resource + end end def api_get_path diff --git a/qa/qa/resource/ssh_key.rb b/qa/qa/resource/ssh_key.rb index c140cb9ca623f876d9cd54d365e386aa246fa4b1..22bdea424ca8498165a1af835ba1d157fde4cc1d 100644 --- a/qa/qa/resource/ssh_key.rb +++ b/qa/qa/resource/ssh_key.rb @@ -7,7 +7,7 @@ module QA attr_accessor :title - def_delegators :key, :private_key, :public_key, :fingerprint + def_delegators :key, :private_key, :public_key, :md5_fingerprint def key @key ||= Runtime::Key::RSA.new diff --git a/qa/qa/runtime/application_settings.rb b/qa/qa/runtime/application_settings.rb new file mode 100644 index 0000000000000000000000000000000000000000..df6323f9a488435578944dcb0161bb1568c31b36 --- /dev/null +++ b/qa/qa/runtime/application_settings.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module QA + module Runtime + module ApplicationSettings + extend self + extend Support::Api + + APPLICATION_SETTINGS_PATH = '/application/settings' + + # Set a GitLab application setting + # Example: + # #set({ allow_local_requests_from_web_hooks_and_services: true }) + # #set(allow_local_requests_from_web_hooks_and_services: true) + # https://docs.gitlab.com/ee/api/settings.html + def set_application_settings(**application_settings) + QA::Runtime::Logger.info("Setting application settings: #{application_settings}") + r = put(Runtime::API::Request.new(api_client, APPLICATION_SETTINGS_PATH).url, **application_settings) + raise "Couldn't set application settings #{application_settings.inspect}" unless r.code == QA::Support::Api::HTTP_STATUS_OK + end + + def get_application_settings + parse_body(get(Runtime::API::Request.new(api_client, APPLICATION_SETTINGS_PATH).url)) + end + + private + + def api_client + @api_client ||= begin + return Runtime::API::Client.new(:gitlab, personal_access_token: Runtime::Env.admin_personal_access_token) if Runtime::Env.admin_personal_access_token + + user = Resource::User.fabricate_via_api! do |user| + user.username = Runtime::User.admin_username + user.password = Runtime::User.admin_password + end + + unless user.admin? + raise "Administrator access is required to set application settings. User '#{user.username}' is not an administrator." + end + + Runtime::API::Client.new(:gitlab, user: user) + end + end + end + end +end diff --git a/qa/qa/runtime/browser.rb b/qa/qa/runtime/browser.rb index 69ba90702be322753cc40ed7b546e73bb7e8378d..340f6dc0356a918ae3363901b07597702b4f3789 100644 --- a/qa/qa/runtime/browser.rb +++ b/qa/qa/runtime/browser.rb @@ -15,6 +15,10 @@ module QA CAPYBARA_MAX_WAIT_TIME = 10 + class << self + attr_accessor :rspec_configured, :capybara_configured + end + def initialize self.class.configure! end @@ -45,11 +49,40 @@ module QA end def self.configure! + configure_rspec! + configure_capybara! + end + + def self.configure_rspec! + # We don't want to enter this infinite loop: + # Runtime::Release.perform_before_hooks -> `QA::Runtime::Browser.visit` -> configure! -> configure_rspec! -> Runtime::Release.perform_before_hooks + # So we make sure this method is called only once. + return if self.rspec_configured + + browser = self + RSpec.configure do |config| config.define_derived_metadata(file_path: %r{/qa/specs/features/}) do |metadata| metadata[:type] = :feature end + + config.before do + unless browser.rspec_configured + browser.rspec_configured = true + + ## + # Perform before hooks, which are different for CE and EE + # + Runtime::Release.perform_before_hooks + end + end end + end + + def self.configure_capybara! + return if self.capybara_configured + + self.capybara_configured = true Capybara.server_port = 9887 + ENV['TEST_ENV_NUMBER'].to_i diff --git a/qa/qa/runtime/env.rb b/qa/qa/runtime/env.rb index 184ccd3ef07abae968829c3af1157376636ef1ee..6514e41e279de79b4567df5e84b388b1c6fbf6c4 100644 --- a/qa/qa/runtime/env.rb +++ b/qa/qa/runtime/env.rb @@ -248,6 +248,10 @@ module QA raise ArgumentError, "Please provide GITHUB_ACCESS_TOKEN" end + def require_admin_access_token! + admin_personal_access_token || (raise ArgumentError, "GITLAB_QA_ADMIN_ACCESS_TOKEN is required!") + end + # Returns true if there is an environment variable that indicates that # the feature is supported in the environment under test. # All features are supported by default. diff --git a/qa/qa/runtime/feature.rb b/qa/qa/runtime/feature.rb index 8c19436ee12b679cc3805c5e7993c7b0b746f21d..25fc02a887e474b2bc1bbb98514a14c175433f5d 100644 --- a/qa/qa/runtime/feature.rb +++ b/qa/qa/runtime/feature.rb @@ -33,7 +33,7 @@ module QA is_enabled = false - QA::Support::Waiter.wait(interval: 1) do + QA::Support::Waiter.wait_until(sleep_interval: 1) do is_enabled = enabled?(key) end diff --git a/qa/qa/runtime/key/base.rb b/qa/qa/runtime/key/base.rb index 1281eceaff0542027fa41c3621ea46298a642043..72d1673438a576233681578e6ea4ef79ce594fea 100644 --- a/qa/qa/runtime/key/base.rb +++ b/qa/qa/runtime/key/base.rb @@ -4,7 +4,7 @@ module QA module Runtime module Key class Base - attr_reader :name, :bits, :private_key, :public_key, :fingerprint + attr_reader :name, :bits, :private_key, :public_key, :md5_fingerprint def initialize(name, bits) @name = name @@ -29,7 +29,7 @@ module QA def populate_key_data(path) @private_key = ::File.binread(path) @public_key = ::File.binread("#{path}.pub") - @fingerprint = + @md5_fingerprint = `ssh-keygen -l -E md5 -f #{path} | cut -d' ' -f2 | cut -d: -f2-`.chomp end end diff --git a/qa/qa/runtime/logger.rb b/qa/qa/runtime/logger.rb index 7f73f1bd01bfd4a9d1bcb71486f0a5a1586e0ba6..a70c8faf7d2b6c5cd5adaa73134adac58261673e 100644 --- a/qa/qa/runtime/logger.rb +++ b/qa/qa/runtime/logger.rb @@ -14,11 +14,9 @@ module QA attr_writer :logger def logger - return @logger if @logger - - @logger = ::Logger.new Runtime::Env.log_destination - @logger.level = Runtime::Env.debug? ? ::Logger::DEBUG : ::Logger::ERROR - @logger + @logger ||= ::Logger.new(Runtime::Env.log_destination).tap do |logger| + logger.level = Runtime::Env.debug? ? ::Logger::DEBUG : ::Logger::ERROR + end end end end diff --git a/qa/qa/runtime/search.rb b/qa/qa/runtime/search.rb index faa110c96e777d39f4fc35a1047e1758d5587a05..744023010987c947a4f685ef74b013b08feb43b1 100644 --- a/qa/qa/runtime/search.rb +++ b/qa/qa/runtime/search.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require 'securerandom' + module QA module Runtime module Search @@ -8,26 +10,83 @@ module QA ElasticSearchServerError = Class.new(RuntimeError) - def elasticsearch_responding? + def assert_elasticsearch_responding QA::Runtime::Logger.debug("Attempting to search via Elasticsearch...") - QA::Support::Retrier.retry_on_exception do - # We don't care about the results of the search, we just need - # any search that uses Elasticsearch, not the native search - # The Elasticsearch-only scopes are blobs, wiki_blobs, and commits. - request = Runtime::API::Request.new(api_client, "/search?scope=blobs&search=foo") - response = get(request.url) + QA::Support::Retrier.retry_on_exception(max_attempts: 3) do + search_term = SecureRandom.hex(8) + + QA::Runtime::Logger.debug("Creating commit and project including search term '#{search_term}'...") - unless response.code == singleton_class::HTTP_STATUS_OK - raise ElasticSearchServerError, "Search attempt failed. Request returned (#{response.code}): `#{response}`." + content = "Elasticsearch test commit #{search_term}" + project = Resource::Project.fabricate_via_api! do |project| + project.name = "project-to-search-#{search_term}" + end + commit = Resource::Repository::Commit.fabricate_via_api! do |commit| + commit.project = project + commit.commit_message = content + commit.add_files( + [ + { + file_path: 'test.txt', + content: content + } + ] + ) end - true + find_commit(commit, "commit*#{search_term}") + find_project(project, "to-search*#{search_term}") + end + end + + def find_code(file_name, search_term) + find_target_in_scope('blobs', search_term) do |record| + record[:filename] == file_name && record[:data].include?(search_term) end + + QA::Runtime::Logger.debug("Found file '#{file_name} containing code '#{search_term}'") + end + + def find_commit(commit, search_term) + find_target_in_scope('commits', search_term) do |record| + record[:message] == commit.commit_message + end + + QA::Runtime::Logger.debug("Found commit '#{commit.commit_message} (#{commit.short_id})' via '#{search_term}'") + end + + def find_project(project, search_term) + find_target_in_scope('projects', search_term) do |record| + record[:name] == project.name + end + + QA::Runtime::Logger.debug("Found project '#{project.name}' via '#{search_term}'") end private + def find_target_in_scope(scope, search_term) + QA::Support::Retrier.retry_until(max_attempts: 10, sleep_interval: 10, raise_on_failure: true, retry_on_exception: true) do + result = search(scope, search_term) + result && result.any? { |record| yield record } + end + end + + def search(scope, term) + QA::Runtime::Logger.debug("Search scope '#{scope}' for '#{term}'...") + request = Runtime::API::Request.new(api_client, "/search?scope=#{scope}&search=#{term}") + response = get(request.url) + + unless response.code == singleton_class::HTTP_STATUS_OK + msg = "Search attempt failed. Request returned (#{response.code}): `#{response}`." + QA::Runtime::Logger.debug(msg) + raise ElasticSearchServerError, msg + end + + parse_body(response) + end + def api_client @api_client ||= Runtime::API::Client.new(:gitlab) end diff --git a/qa/qa/scenario/template.rb b/qa/qa/scenario/template.rb index 74d4c8f8757649fe2e78aae1bc02ae8e5e37fc09..97373f7a0593ead503689ae5eb5c5c92d782fe25 100644 --- a/qa/qa/scenario/template.rb +++ b/qa/qa/scenario/template.rb @@ -23,11 +23,6 @@ module QA def perform(options, *args) extract_address(:gitlab_address, options, args) - ## - # Perform before hooks, which are different for CE and EE - # - Runtime::Release.perform_before_hooks - Runtime::Feature.enable(options[:enable_feature]) if options.key?(:enable_feature) Specs::Runner.perform do |specs| diff --git a/qa/qa/scenario/test/instance.rb b/qa/qa/scenario/test/instance.rb index b4098619e4e9c25830abcfb00efe5b60345176e8..79dad7f46192c9bfd84746c6dc40b13f3821d282 100644 --- a/qa/qa/scenario/test/instance.rb +++ b/qa/qa/scenario/test/instance.rb @@ -20,11 +20,6 @@ module QA def self.do_perform(address, *rspec_options) Runtime::Scenario.define(:gitlab_address, address) - ## - # Perform before hooks, which are different for CE and EE - # - Runtime::Release.perform_before_hooks - Specs::Runner.perform do |specs| specs.tty = true specs.options = rspec_options if rspec_options.any? diff --git a/qa/qa/service/cluster_provider/k3d.rb b/qa/qa/service/cluster_provider/k3d.rb index 8e117c2dbd5ce2d6863780ca7d12192580e25172..fe02dde607c4e53aa51c128cf3a2ac0606e4fbe5 100644 --- a/qa/qa/service/cluster_provider/k3d.rb +++ b/qa/qa/service/cluster_provider/k3d.rb @@ -6,6 +6,8 @@ module QA class K3d < Base def validate_dependencies find_executable('k3d') || raise("You must first install `k3d` executable to run these tests.") + Runtime::Env.require_admin_access_token! + Runtime::ApplicationSettings.set_application_settings(allow_local_requests_from_web_hooks_and_services: true) end def set_credentials(admin_user) @@ -24,6 +26,7 @@ module QA def teardown ENV['KUBECONFIG'] = @old_kubeconfig shell "k3d delete --name #{cluster_name}" + Runtime::ApplicationSettings.set_application_settings(allow_local_requests_from_web_hooks_and_services: false) end # Fetch "real" certificate diff --git a/qa/qa/service/kubernetes_cluster.rb b/qa/qa/service/kubernetes_cluster.rb index 26b5f58d2d3ba59014adc87fa2578916605d92c9..841965565474fe57c0ab5209cf1ef5c6df6d82ec 100644 --- a/qa/qa/service/kubernetes_cluster.rb +++ b/qa/qa/service/kubernetes_cluster.rb @@ -39,6 +39,10 @@ module QA @provider.cluster_name end + def to_s + cluster_name + end + private def fetch_api_url diff --git a/qa/qa/specs/features/browser_ui/1_manage/login/login_via_oauth_spec.rb b/qa/qa/specs/features/browser_ui/1_manage/login/login_via_oauth_spec.rb index 4fd80c353fb33b2308472e11fcd8c374353a8b3d..70303a3015381172bba7edade967dd4e2ddaa9c5 100644 --- a/qa/qa/specs/features/browser_ui/1_manage/login/login_via_oauth_spec.rb +++ b/qa/qa/specs/features/browser_ui/1_manage/login/login_via_oauth_spec.rb @@ -1,8 +1,7 @@ # frozen_string_literal: true module QA - # Failure issue: https://gitlab.com/gitlab-org/gitlab/issues/36305 - context 'Manage', :orchestrated, :oauth, :skip do + context 'Manage', :orchestrated, :oauth, quarantine: 'https://gitlab.com/gitlab-org/gitlab/issues/196517' do describe 'OAuth login' do it 'User logs in to GitLab with GitHub OAuth' do Runtime::Browser.visit(:gitlab, Page::Main::Login) diff --git a/qa/qa/specs/features/browser_ui/1_manage/project/import_github_repo_spec.rb b/qa/qa/specs/features/browser_ui/1_manage/project/import_github_repo_spec.rb index e0045a4d8a12eb5ef488a0e547e170d63eac7684..14eaf770f10c05c2c5337d4989550b75b1242d8c 100644 --- a/qa/qa/specs/features/browser_ui/1_manage/project/import_github_repo_spec.rb +++ b/qa/qa/specs/features/browser_ui/1_manage/project/import_github_repo_spec.rb @@ -21,6 +21,8 @@ module QA delete delete_project_request.url expect_status(202) + + Page::Main::Menu.perform(&:sign_out_if_signed_in) end it 'user imports a GitHub repo' do diff --git a/qa/qa/specs/features/browser_ui/2_plan/email/trigger_email_notification_spec.rb b/qa/qa/specs/features/browser_ui/2_plan/email/trigger_email_notification_spec.rb index 90290b4f2a093c3baf072cf134a440e8e6cca55d..eecf485a5181d2c7e39bd446055a3679bcd30c0c 100644 --- a/qa/qa/specs/features/browser_ui/2_plan/email/trigger_email_notification_spec.rb +++ b/qa/qa/specs/features/browser_ui/2_plan/email/trigger_email_notification_spec.rb @@ -22,8 +22,15 @@ module QA expect(page).to have_content(/@#{user.username}(\n| )?Given access/) - # Wait for Action Mailer to deliver messages - mailhog_json = Support::Retrier.retry_until(sleep_interval: 1) do + mailhog_items = mailhog_json.dig('items') + + expect(mailhog_items).to include(an_object_satisfying { |o| /project was granted/ === o.dig('Content', 'Headers', 'Subject', 0) }) + end + + private + + def mailhog_json + Support::Retrier.retry_until(sleep_interval: 1) do Runtime::Logger.debug(%Q[retrieving "#{QA::Runtime::MailHog.api_messages_url}"]) mailhog_response = get QA::Runtime::MailHog.api_messages_url @@ -33,10 +40,6 @@ module QA # Expect at least two invitation messages: group and project mailhog_data if mailhog_data.dig('total') >= 2 end - - # Check json result from mailhog - mailhog_items = mailhog_json.dig('items') - expect(mailhog_items).to include(an_object_satisfying { |o| /project was granted/ === o.dig('Content', 'Headers', 'Subject', 0) }) end end end diff --git a/qa/qa/specs/features/browser_ui/2_plan/issue/check_mentions_for_xss_spec.rb b/qa/qa/specs/features/browser_ui/2_plan/issue/check_mentions_for_xss_spec.rb index 494108dbefc7ebaf47c880978306d90cc99611d1..aa88937504efd8861c4dd35e6696f67d132e1553 100644 --- a/qa/qa/specs/features/browser_ui/2_plan/issue/check_mentions_for_xss_spec.rb +++ b/qa/qa/specs/features/browser_ui/2_plan/issue/check_mentions_for_xss_spec.rb @@ -24,25 +24,19 @@ module QA project = Resource::Project.fabricate_via_api! do |resource| resource.name = 'xss-test-for-mentions-project' end - project.visit! - Page::Project::Show.perform(&:go_to_members_settings) - Page::Project::Settings::Members.perform do |members| - members.add_member(user.username) - end + Flow::Project.add_member(project: project, username: user.username) - issue = Resource::Issue.fabricate_via_api! do |issue| - issue.title = 'issue title' + Resource::Issue.fabricate_via_api! do |issue| issue.project = project - end - issue.visit! + end.visit! Page::Project::Issue::Show.perform do |show| show.select_all_activities_filter show.comment("cc-ing you here @#{user.username}") expect do - expect(show).to have_content("cc-ing you here") + expect(show).to have_comment("cc-ing you here") end.not_to raise_error # Selenium::WebDriver::Error::UnhandledAlertError end end diff --git a/qa/qa/specs/features/browser_ui/2_plan/issue/close_issue_spec.rb b/qa/qa/specs/features/browser_ui/2_plan/issue/close_issue_spec.rb index bab6b1ac5fcd51c4f061347d99ebef26704f84f3..2543c0091fb5a83845f51e74adef0495df6edff6 100644 --- a/qa/qa/specs/features/browser_ui/2_plan/issue/close_issue_spec.rb +++ b/qa/qa/specs/features/browser_ui/2_plan/issue/close_issue_spec.rb @@ -4,12 +4,9 @@ module QA context 'Plan' do describe 'Close issue' do let(:issue) do - Resource::Issue.fabricate_via_api! do |issue| - issue.title = 'Issue to be closed via pushing a commit' - end + Resource::Issue.fabricate_via_api! end - let(:project) { issue.project } let(:issue_id) { issue.api_response[:iid] } before do @@ -27,7 +24,7 @@ module QA issue.visit! Page::Project::Issue::Show.perform do |show| - reopen_issue_button_visible = show.wait(reload: true) do + reopen_issue_button_visible = show.wait_until(reload: true) do show.has_element?(:reopen_issue_button, wait: 1.0) end expect(reopen_issue_button_visible).to be_truthy @@ -39,7 +36,7 @@ module QA push.commit_message = commit_message push.new_branch = new_branch push.file_content = commit_message - push.project = project + push.project = issue.project end end end diff --git a/qa/qa/specs/features/browser_ui/2_plan/issue/collapse_comments_in_discussions_spec.rb b/qa/qa/specs/features/browser_ui/2_plan/issue/collapse_comments_in_discussions_spec.rb index 77fcc4e9b6ac64263aa7c1dff55946a48cacb556..e505c0991a6ad6895aaa6a49221ca2a0451afad2 100644 --- a/qa/qa/specs/features/browser_ui/2_plan/issue/collapse_comments_in_discussions_spec.rb +++ b/qa/qa/specs/features/browser_ui/2_plan/issue/collapse_comments_in_discussions_spec.rb @@ -8,20 +8,12 @@ module QA before do Flow::Login.sign_in - issue = Resource::Issue.fabricate_via_api! do |issue| - issue.title = 'issue title' - end - - issue.visit! + Resource::Issue.fabricate_via_api!.visit! Page::Project::Issue::Show.perform do |show| - my_first_discussion = 'My first discussion' - show.select_all_activities_filter - show.start_discussion(my_first_discussion) - page.assert_text(my_first_discussion) - show.reply_to_discussion(my_first_reply) - page.assert_text(my_first_reply) + show.start_discussion('My first discussion') + show.reply_to_discussion(1, my_first_reply) end end diff --git a/qa/qa/specs/features/browser_ui/2_plan/issue/comment_issue_spec.rb b/qa/qa/specs/features/browser_ui/2_plan/issue/comment_issue_spec.rb index 77489c0ecf558f74bbc662e9e3a385c44ea375a3..6c37e3ecbb9ce57d7de5277a486ecb1bf9feb055 100644 --- a/qa/qa/specs/features/browser_ui/2_plan/issue/comment_issue_spec.rb +++ b/qa/qa/specs/features/browser_ui/2_plan/issue/comment_issue_spec.rb @@ -6,10 +6,7 @@ module QA before do Flow::Login.sign_in - issue = Resource::Issue.fabricate_via_api! do |issue| - issue.title = 'issue title' - end - issue.visit! + Resource::Issue.fabricate_via_api!.visit! end it 'user comments on an issue and edits the comment' do diff --git a/qa/qa/specs/features/browser_ui/2_plan/issue/create_issue_spec.rb b/qa/qa/specs/features/browser_ui/2_plan/issue/create_issue_spec.rb index 254efb741b317fa34f851132ca3f428180908d90..3b231b9930e145d40a89113b6dfb4eac903c65cb 100644 --- a/qa/qa/specs/features/browser_ui/2_plan/issue/create_issue_spec.rb +++ b/qa/qa/specs/features/browser_ui/2_plan/issue/create_issue_spec.rb @@ -3,16 +3,12 @@ module QA context 'Plan', :smoke do describe 'Issue creation' do - let(:issue_title) { 'issue title' } - before do Flow::Login.sign_in end it 'user creates an issue' do - issue = Resource::Issue.fabricate_via_browser_ui! do |issue| - issue.title = issue_title - end + issue = Resource::Issue.fabricate_via_browser_ui! Page::Project::Menu.perform(&:click_issues) @@ -28,11 +24,7 @@ module QA end before do - issue = Resource::Issue.fabricate_via_api! do |issue| - issue.title = issue_title - end - - issue.visit! + Resource::Issue.fabricate_via_api!.visit! end it 'user comments on an issue with an attachment' do diff --git a/qa/qa/specs/features/browser_ui/2_plan/issue/filter_issue_comments_spec.rb b/qa/qa/specs/features/browser_ui/2_plan/issue/filter_issue_comments_spec.rb index a4f6b0bb1bf7c2ebb96853ae84ad2c8a98c76122..4156ba547859737de115247910defbdc82e902ae 100644 --- a/qa/qa/specs/features/browser_ui/2_plan/issue/filter_issue_comments_spec.rb +++ b/qa/qa/specs/features/browser_ui/2_plan/issue/filter_issue_comments_spec.rb @@ -3,16 +3,10 @@ module QA context 'Plan' do describe 'filter issue comments activities' do - let(:issue_title) { 'issue title' } - before do Flow::Login.sign_in - issue = Resource::Issue.fabricate_via_api! do |issue| - issue.title = issue_title - end - - issue.visit! + Resource::Issue.fabricate_via_api!.visit! end it 'user filters comments and activities in an issue' do diff --git a/qa/qa/specs/features/browser_ui/2_plan/issue/mentions_spec.rb b/qa/qa/specs/features/browser_ui/2_plan/issue/mentions_spec.rb index b1a80ad75cd3a988d084d8e14df411708913b049..a0647df409719dd945f3c21b65a537599e6f7fad 100644 --- a/qa/qa/specs/features/browser_ui/2_plan/issue/mentions_spec.rb +++ b/qa/qa/specs/features/browser_ui/2_plan/issue/mentions_spec.rb @@ -12,18 +12,12 @@ module QA resource.name = 'project-to-test-mention' resource.visibility = 'private' end - project.visit! - Page::Project::Show.perform(&:go_to_members_settings) - Page::Project::Settings::Members.perform do |members| - members.add_member(@user.username) - end + project.add_member(@user) - issue = Resource::Issue.fabricate_via_api! do |issue| - issue.title = 'issue to test mention' + Resource::Issue.fabricate_via_api! do |issue| issue.project = project - end - issue.visit! + end.visit! end it 'user mentions another user in an issue' do diff --git a/qa/qa/specs/features/browser_ui/3_create/merge_request/view_merge_request_diff_patch_spec.rb b/qa/qa/specs/features/browser_ui/3_create/merge_request/view_merge_request_diff_patch_spec.rb index 0eaec61b2fa2ecc6298cb99f9203aae08d43d51e..604b6c10aeed9ce7381ff29d94f0cc795e014f6c 100644 --- a/qa/qa/specs/features/browser_ui/3_create/merge_request/view_merge_request_diff_patch_spec.rb +++ b/qa/qa/specs/features/browser_ui/3_create/merge_request/view_merge_request_diff_patch_spec.rb @@ -4,12 +4,7 @@ module QA context 'Create' do describe 'Download merge request patch and diff' do before(:context) do - project = Resource::Project.fabricate_via_api! do |project| - project.name = 'project' - end - @merge_request = Resource::MergeRequest.fabricate_via_api! do |merge_request| - merge_request.project = project merge_request.title = 'This is a merge request' merge_request.description = '... for downloading patches and diffs' end @@ -23,7 +18,7 @@ module QA expect(page.text).to start_with('From') expect(page).to have_content('Subject: [PATCH] This is a test commit') - expect(page).to have_content('diff --git a/added_file.txt b/added_file.txt') + expect(page).to have_content("diff --git a/#{@merge_request.file_name} b/#{@merge_request.file_name}") end it 'views the merge request plain diff' do @@ -32,7 +27,7 @@ module QA @merge_request.visit! Page::MergeRequest::Show.perform(&:view_plain_diff) - expect(page.text).to start_with('diff --git a/added_file.txt b/added_file.txt') + expect(page.text).to start_with("diff --git a/#{@merge_request.file_name} b/#{@merge_request.file_name}") expect(page).to have_content('+File Added') end end diff --git a/qa/qa/specs/features/browser_ui/3_create/repository/add_ssh_key_spec.rb b/qa/qa/specs/features/browser_ui/3_create/repository/add_ssh_key_spec.rb index 474a7904fea423ee13dc8f7962f5edbe7f021bb0..c3379d41ff213a9d28ad558def097cf3cade1746 100644 --- a/qa/qa/specs/features/browser_ui/3_create/repository/add_ssh_key_spec.rb +++ b/qa/qa/specs/features/browser_ui/3_create/repository/add_ssh_key_spec.rb @@ -13,7 +13,7 @@ module QA end expect(page).to have_content("Title: #{key_title}") - expect(page).to have_content(key.fingerprint) + expect(page).to have_content(key.md5_fingerprint) Page::Main::Menu.perform(&:click_settings_link) Page::Profile::Menu.perform(&:click_ssh_keys) @@ -23,7 +23,7 @@ module QA end expect(page).not_to have_content("Title: #{key_title}") - expect(page).not_to have_content(key.fingerprint) + expect(page).not_to have_content(key.md5_fingerprint) end end end diff --git a/qa/qa/specs/features/browser_ui/3_create/web_ide/add_file_template_spec.rb b/qa/qa/specs/features/browser_ui/3_create/web_ide/add_file_template_spec.rb index 7c9db5ee496e70000c78b9dfe68ce4cfd15dac57..70b571a316a3e347d52f46a504f0c00ee5592cc1 100644 --- a/qa/qa/specs/features/browser_ui/3_create/web_ide/add_file_template_spec.rb +++ b/qa/qa/specs/features/browser_ui/3_create/web_ide/add_file_template_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module QA - context 'Create' do + context 'Create', quarantine: 'https://gitlab.com/gitlab-org/gitlab/issues/196034' do describe 'Web IDE file templates' do include Runtime::Fixtures diff --git a/qa/qa/specs/features/browser_ui/6_release/deploy_key/add_deploy_key_spec.rb b/qa/qa/specs/features/browser_ui/6_release/deploy_key/add_deploy_key_spec.rb index 9c964c726f1d43657126344a6f881c63ada5822d..89aba112407f7d6182c8d5be4170aa633d622308 100644 --- a/qa/qa/specs/features/browser_ui/6_release/deploy_key/add_deploy_key_spec.rb +++ b/qa/qa/specs/features/browser_ui/6_release/deploy_key/add_deploy_key_spec.rb @@ -15,11 +15,11 @@ module QA resource.key = deploy_key_value end - expect(deploy_key.fingerprint).to eq key.fingerprint + expect(deploy_key.md5_fingerprint).to eq key.md5_fingerprint Page::Project::Settings::Repository.perform do |setting| setting.expand_deploy_keys do |keys| - expect(keys).to have_key(deploy_key_title, key.fingerprint) + expect(keys).to have_key(deploy_key_title, key.md5_fingerprint) end end end diff --git a/qa/qa/specs/features/browser_ui/6_release/deploy_key/clone_using_deploy_key_spec.rb b/qa/qa/specs/features/browser_ui/6_release/deploy_key/clone_using_deploy_key_spec.rb index 3badaa983cbef86eb4621bb1b016323c5caea262..0ca49bd080b90da3a74a5bebdb3a79f660588612 100644 --- a/qa/qa/specs/features/browser_ui/6_release/deploy_key/clone_using_deploy_key_spec.rb +++ b/qa/qa/specs/features/browser_ui/6_release/deploy_key/clone_using_deploy_key_spec.rb @@ -3,13 +3,9 @@ require 'digest/sha1' module QA - context 'Release', :docker do + context 'Release', :docker, quarantine: 'https://gitlab.com/gitlab-org/gitlab/issues/196047' do describe 'Git clone using a deploy key' do before do - # Handle WIP Job Logs flag - https://gitlab.com/gitlab-org/gitlab/issues/31162 - @job_log_json_flag_enabled = Runtime::Feature.enabled?('job_log_json') - Runtime::Feature.disable('job_log_json') if @job_log_json_flag_enabled - Flow::Login.sign_in @runner_name = "qa-runner-#{Time.now.to_i}" @@ -29,7 +25,6 @@ module QA end after do - Runtime::Feature.enable('job_log_json') if @job_log_json_flag_enabled Service::DockerRun::GitlabRunner.new(@runner_name).remove! end diff --git a/qa/qa/specs/features/browser_ui/7_configure/auto_devops/create_project_with_auto_devops_spec.rb b/qa/qa/specs/features/browser_ui/7_configure/auto_devops/create_project_with_auto_devops_spec.rb index a96bfde49f354c4717a59c081fc8a94c6819fdbd..54014ff7067b1690a40f2f00c874780254be5af4 100644 --- a/qa/qa/specs/features/browser_ui/7_configure/auto_devops/create_project_with_auto_devops_spec.rb +++ b/qa/qa/specs/features/browser_ui/7_configure/auto_devops/create_project_with_auto_devops_spec.rb @@ -50,7 +50,8 @@ module QA end end - describe 'Auto DevOps support', :orchestrated, :kubernetes do + # Failure issue: https://gitlab.com/gitlab-org/gitlab/issues/118481 + describe 'Auto DevOps support', :orchestrated, :kubernetes, :quarantine do context 'when rbac is enabled' do before(:all) do @cluster = Service::KubernetesCluster.new.create! diff --git a/qa/qa/specs/features/browser_ui/7_configure/kubernetes/kubernetes_integration_spec.rb b/qa/qa/specs/features/browser_ui/7_configure/kubernetes/kubernetes_integration_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..73b5a579e086aa245a403e579cf0bb68af451f0f --- /dev/null +++ b/qa/qa/specs/features/browser_ui/7_configure/kubernetes/kubernetes_integration_spec.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +module QA + context 'Configure' do + # This test requires GITLAB_QA_ADMIN_ACCESS_TOKEN to be specified + describe 'Kubernetes Cluster Integration', :orchestrated, :kubernetes, :requires_admin, :skip do + context 'Project Clusters' do + let(:cluster) { Service::KubernetesCluster.new(provider_class: Service::ClusterProvider::K3d).create! } + let(:project) do + Resource::Project.fabricate_via_api! do |project| + project.name = 'project-with-k8s' + project.description = 'Project with Kubernetes cluster integration' + end + end + + before do + Flow::Login.sign_in + end + + after do + cluster.remove! + end + + it 'can create and associate a project cluster', :smoke do + Resource::KubernetesCluster.fabricate_via_browser_ui! do |k8s_cluster| + k8s_cluster.project = project + k8s_cluster.cluster = cluster + end + + project.visit! + + Page::Project::Menu.perform(&:go_to_operations_kubernetes) + + Page::Project::Operations::Kubernetes::Index.perform do |index| + expect(index).to have_cluster(cluster) + end + end + + it 'installs helm and tiller on a gitlab managed app' do + Resource::KubernetesCluster.fabricate_via_browser_ui! do |k8s_cluster| + k8s_cluster.project = project + k8s_cluster.cluster = cluster + k8s_cluster.install_helm_tiller = true + end + + Page::Project::Operations::Kubernetes::Show.perform do |show| + expect(show).to have_application_installed(:helm) + end + end + end + end + end +end diff --git a/qa/qa/specs/features/browser_ui/non_devops/performance_bar_spec.rb b/qa/qa/specs/features/browser_ui/non_devops/performance_bar_spec.rb index b067a44e325aff812c27f3913722f839ebb20eca..4a5bb077e69dd3fb1d073d01512a4106a3879de2 100644 --- a/qa/qa/specs/features/browser_ui/non_devops/performance_bar_spec.rb +++ b/qa/qa/specs/features/browser_ui/non_devops/performance_bar_spec.rb @@ -1,32 +1,36 @@ # frozen_string_literal: true module QA - # https://gitlab.com/gitlab-org/gitlab/issues/38315 - context 'Performance bar', :quarantine do - context 'when logged in as an admin user', :requires_admin do - before do - Flow::Login.sign_in_as_admin - Page::Main::Menu.perform(&:go_to_admin_area) - Page::Admin::Menu.perform(&:go_to_metrics_and_profiling_settings) + context 'Non-devops' do + describe 'Performance bar display', :requires_admin do + context 'when logged in as an admin user' do + # 4 metrics: pg, gitaly, redis, total + let(:metrics_count) { 4 } - Page::Admin::Settings::MetricsAndProfiling.perform do |setting| - setting.expand_performance_bar do |page| - page.enable_performance_bar - page.save_settings + before do + Flow::Login.sign_in_as_admin + Page::Main::Menu.perform(&:go_to_admin_area) + Page::Admin::Menu.perform(&:go_to_metrics_and_profiling_settings) + + Page::Admin::Settings::MetricsAndProfiling.perform do |setting| + setting.expand_performance_bar do |page| + page.enable_performance_bar + page.save_settings + end end end - end - it 'shows results for the original request and AJAX requests' do - # Issue pages always make AJAX requests - Resource::Issue.fabricate_via_browser_ui! do |issue| - issue.title = 'Performance bar test' - end + it 'shows results for the original request and AJAX requests' do + # Issue pages always make AJAX requests + Resource::Issue.fabricate_via_browser_ui! do |issue| + issue.title = 'Performance bar test' + end - Page::Layout::PerformanceBar.perform do |bar_component| - expect(bar_component).to have_performance_bar - expect(bar_component).to have_detailed_metrics - expect(bar_component).to have_request_for('realtime_changes') # Always requested on issue pages + Page::Layout::PerformanceBar.perform do |bar_component| + expect(bar_component).to have_performance_bar + expect(bar_component).to have_detailed_metrics(metrics_count) + expect(bar_component).to have_request_for('realtime_changes') # Always requested on issue pages + end end end end diff --git a/qa/qa/specs/helpers/quarantine.rb b/qa/qa/specs/helpers/quarantine.rb index ca0ce32e74f3a77d1f1ad53c870ba4b1eda19acd..8b14184f3b7b28840723fd503c2757b00c1d4cb5 100644 --- a/qa/qa/specs/helpers/quarantine.rb +++ b/qa/qa/specs/helpers/quarantine.rb @@ -43,7 +43,23 @@ module QA::Specs::Helpers # the quarantined tests when they're not run so that we're aware of them skip("Only running tests tagged with :quarantine and any of #{included_filters.keys}") if should_skip_when_focused?(example.metadata, included_filters) else - skip('In quarantine') if example.metadata.key?(:quarantine) + if example.metadata.key?(:quarantine) + quarantine_message = %w(In quarantine) + quarantine_tag = example.metadata[:quarantine] + + if !!quarantine_tag + quarantine_message << case quarantine_tag + when String + ": #{quarantine_tag}" + when Hash + ": #{quarantine_tag[:issue]}" + else + '' + end + end + + skip(quarantine_message.join(' ').strip) + end end end diff --git a/qa/qa/support/page/logging.rb b/qa/qa/support/page/logging.rb index 5d73d9635f86d13a67eac352c9f5ab60ded5f878..f59795e17c3689e429f2976588d2a75ed3165795 100644 --- a/qa/qa/support/page/logging.rb +++ b/qa/qa/support/page/logging.rb @@ -16,7 +16,7 @@ module QA super end - def wait(max: 60, interval: 0.1, reload: true) + def wait_until(max_duration: 60, sleep_interval: 0.1, reload: true, raise_on_failure: false) log("next wait uses reload: #{reload}") # Logging of wait start/end/duration is handled by QA::Support::Waiter @@ -119,10 +119,10 @@ module QA found end - def has_no_text?(text) + def has_no_text?(text, **kwargs) found = super - log(%Q{has_no_text?('#{text}') returned #{found}}) + log(%Q{has_no_text?('#{text}', wait: #{kwargs[:wait] || Capybara.default_max_wait_time}) returned #{found}}) found end @@ -173,6 +173,7 @@ module QA def log_has_element_or_not(method, name, found, **kwargs) msg = ["#{method} :#{name}"] msg << %Q(with text "#{kwargs[:text]}") if kwargs[:text] + msg << "class: #{kwargs[:class]}" if kwargs[:class] msg << "(wait: #{kwargs[:wait] || Capybara.default_max_wait_time})" msg << "returned: #{found}" diff --git a/qa/qa/support/repeater.rb b/qa/qa/support/repeater.rb new file mode 100644 index 0000000000000000000000000000000000000000..53d72f2f410e1797ca8a4b25e1e8f4249e805242 --- /dev/null +++ b/qa/qa/support/repeater.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require 'active_support/inflector' + +module QA + module Support + module Repeater + DEFAULT_MAX_WAIT_TIME = 60 + + RetriesExceededError = Class.new(RuntimeError) + WaitExceededError = Class.new(RuntimeError) + + def repeat_until(max_attempts: nil, max_duration: nil, reload_page: nil, sleep_interval: 0, raise_on_failure: true, retry_on_exception: false) + attempts = 0 + start = Time.now + + begin + while remaining_attempts?(attempts, max_attempts) && remaining_time?(start, max_duration) + QA::Runtime::Logger.debug("Attempt number #{attempts + 1}") if max_attempts + + result = yield + return result if result + + sleep_and_reload_if_needed(sleep_interval, reload_page) + attempts += 1 + end + rescue StandardError, RSpec::Expectations::ExpectationNotMetError + raise unless retry_on_exception + + attempts += 1 + if remaining_attempts?(attempts, max_attempts) && remaining_time?(start, max_duration) + sleep_and_reload_if_needed(sleep_interval, reload_page) + + retry + else + raise + end + end + + if raise_on_failure + raise RetriesExceededError, "Retry condition not met after #{max_attempts} #{'attempt'.pluralize(max_attempts)}" unless remaining_attempts?(attempts, max_attempts) + + raise WaitExceededError, "Wait condition not met after #{max_duration} #{'second'.pluralize(max_duration)}" + end + + false + end + + private + + def sleep_and_reload_if_needed(sleep_interval, reload_page) + sleep(sleep_interval) + reload_page.refresh if reload_page + end + + def remaining_attempts?(attempts, max_attempts) + max_attempts ? attempts < max_attempts : true + end + + def remaining_time?(start, max_duration) + max_duration ? Time.now - start < max_duration : true + end + end + end +end diff --git a/qa/qa/support/retrier.rb b/qa/qa/support/retrier.rb index 3b02cb4855be48ce84240042670e1efbc5d09a08..7b548e9545303cc1e38762be732832c9d5119888 100644 --- a/qa/qa/support/retrier.rb +++ b/qa/qa/support/retrier.rb @@ -3,49 +3,61 @@ module QA module Support module Retrier + extend Repeater + module_function def retry_on_exception(max_attempts: 3, reload_page: nil, sleep_interval: 0.5) - QA::Runtime::Logger.debug("with retry_on_exception: max_attempts #{max_attempts}; sleep_interval #{sleep_interval}") - - attempts = 0 + QA::Runtime::Logger.debug( + <<~MSG.tr("\n", ' ') + with retry_on_exception: max_attempts: #{max_attempts}; + reload_page: #{reload_page}; + sleep_interval: #{sleep_interval} + MSG + ) - begin - QA::Runtime::Logger.debug("Attempt number #{attempts + 1}") - yield - rescue StandardError, RSpec::Expectations::ExpectationNotMetError - sleep sleep_interval - reload_page.refresh if reload_page - attempts += 1 + result = nil + repeat_until( + max_attempts: max_attempts, + reload_page: reload_page, + sleep_interval: sleep_interval, + retry_on_exception: true + ) do + result = yield - retry if attempts < max_attempts - QA::Runtime::Logger.debug("Raising exception after #{max_attempts} attempts") - raise + # This method doesn't care what the return value of the block is. + # We set it to `true` so that it doesn't repeat if there's no exception + true end - end - - def retry_until(max_attempts: 3, reload_page: nil, sleep_interval: 0, exit_on_failure: false) - QA::Runtime::Logger.debug("with retry_until: max_attempts #{max_attempts}; sleep_interval #{sleep_interval}; reload_page:#{reload_page}") - attempts = 0 + QA::Runtime::Logger.debug("ended retry_on_exception") - while attempts < max_attempts - QA::Runtime::Logger.debug("Attempt number #{attempts + 1}") - result = yield - return result if result + result + end - sleep sleep_interval + def retry_until(max_attempts: nil, max_duration: nil, reload_page: nil, sleep_interval: 0, raise_on_failure: false, retry_on_exception: false) + # For backwards-compatibility + max_attempts = 3 if max_attempts.nil? && max_duration.nil? - reload_page.refresh if reload_page + start_msg ||= ["with retry_until:"] + start_msg << "max_attempts: #{max_attempts};" if max_attempts + start_msg << "max_duration: #{max_duration};" if max_duration + start_msg << "reload_page: #{reload_page}; sleep_interval: #{sleep_interval}; raise_on_failure: #{raise_on_failure}; retry_on_exception: #{retry_on_exception}" + QA::Runtime::Logger.debug(start_msg.join(' ')) - attempts += 1 - end - - if exit_on_failure - QA::Runtime::Logger.debug("Raising exception after #{max_attempts} attempts") - raise + result = nil + repeat_until( + max_attempts: max_attempts, + max_duration: max_duration, + reload_page: reload_page, + sleep_interval: sleep_interval, + raise_on_failure: raise_on_failure, + retry_on_exception: retry_on_exception + ) do + result = yield end + QA::Runtime::Logger.debug("ended retry_until") - false + result end end end diff --git a/qa/qa/support/wait_for_requests.rb b/qa/qa/support/wait_for_requests.rb new file mode 100644 index 0000000000000000000000000000000000000000..5d5ba70a0c2599d19b50f736a8719f0ea847768b --- /dev/null +++ b/qa/qa/support/wait_for_requests.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module QA + module Support + module WaitForRequests + module_function + + def wait_for_requests + Waiter.wait_until do + finished_all_ajax_requests? && finished_all_axios_requests? + end + end + + def finished_all_axios_requests? + Capybara.page.evaluate_script('window.pendingRequests || 0').zero? + end + + def finished_all_ajax_requests? + return true if Capybara.page.evaluate_script('typeof jQuery === "undefined"') + + Capybara.page.evaluate_script('jQuery.active').zero? + end + end + end +end diff --git a/qa/qa/support/waiter.rb b/qa/qa/support/waiter.rb index fdcf2d7e157b26cc894eb036db5708ee6a93c9d7..fe63c930c7c5c24becd933323df6a8aae19970e3 100644 --- a/qa/qa/support/waiter.rb +++ b/qa/qa/support/waiter.rb @@ -3,30 +3,33 @@ module QA module Support module Waiter - DEFAULT_MAX_WAIT_TIME = 60 + extend Repeater module_function - def wait(max: DEFAULT_MAX_WAIT_TIME, interval: 0.1) - QA::Runtime::Logger.debug("with wait: max #{max}; interval #{interval}") - start = Time.now + def wait_until(max_duration: singleton_class::DEFAULT_MAX_WAIT_TIME, reload_page: nil, sleep_interval: 0.1, raise_on_failure: false, retry_on_exception: false) + QA::Runtime::Logger.debug( + <<~MSG.tr("\n", ' ') + with wait_until: max_duration: #{max_duration}; + reload_page: #{reload_page}; + sleep_interval: #{sleep_interval}; + raise_on_failure: #{raise_on_failure} + MSG + ) - while Time.now - start < max + result = nil + self.repeat_until( + max_duration: max_duration, + reload_page: reload_page, + sleep_interval: sleep_interval, + raise_on_failure: raise_on_failure, + retry_on_exception: retry_on_exception + ) do result = yield - if result - log_end(Time.now - start) - return result - end - - sleep(interval) end - log_end(Time.now - start) - - false - end + QA::Runtime::Logger.debug("ended wait_until") - def self.log_end(duration) - QA::Runtime::Logger.debug("ended wait after #{duration} seconds") + result end end end diff --git a/qa/qa/vendor/github/page/login.rb b/qa/qa/vendor/github/page/login.rb index e581edcb7c7d2fa98ec4f24fd36cb6a470e65e74..4675e33b51437a095034d51893abe5413dfe0c41 100644 --- a/qa/qa/vendor/github/page/login.rb +++ b/qa/qa/vendor/github/page/login.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'capybara/dsl' +require 'benchmark' module QA module Vendor @@ -12,10 +13,16 @@ module QA fill_in 'password', with: QA::Runtime::Env.github_password click_on 'Sign in' - Support::Retrier.retry_until(exit_on_failure: true, sleep_interval: 35) do - otp = OnePassword::CLI.new.otp + Support::Retrier.retry_until(raise_on_failure: true, sleep_interval: 35) do + fresh_otp = nil - fill_in 'otp', with: otp + time = Benchmark.realtime do + fresh_otp = OnePassword::CLI.instance.fresh_otp + end + + QA::Runtime::Logger.info("Returned fresh_otp: #{fresh_otp} in #{time} seconds") + + fill_in 'otp', with: fresh_otp click_on 'Verify' diff --git a/qa/qa/vendor/jenkins/page/configure.rb b/qa/qa/vendor/jenkins/page/configure.rb index 8851a2564fdc658369004396f8515febf4635486..da59060152dfcec007562d523aa058dc7bb63a0d 100644 --- a/qa/qa/vendor/jenkins/page/configure.rb +++ b/qa/qa/vendor/jenkins/page/configure.rb @@ -18,7 +18,7 @@ module QA dropdown_element = find('.setting-name', text: "Credentials").find(:xpath, "..").find('select') - QA::Support::Retrier.retry_until(exit_on_failure: true) do + QA::Support::Retrier.retry_until(raise_on_failure: true) do dropdown_element.select "GitLab API token (#{token_description})" dropdown_element.value != '' end diff --git a/qa/qa/vendor/jenkins/page/login.rb b/qa/qa/vendor/jenkins/page/login.rb index 7b3558b25e203d40d075f27f848af9fd0e1bd436..b18c02b5a4445e07f46d9d877443965cfc16e733 100644 --- a/qa/qa/vendor/jenkins/page/login.rb +++ b/qa/qa/vendor/jenkins/page/login.rb @@ -14,7 +14,7 @@ module QA def visit! super - QA::Support::Retrier.retry_until(sleep_interval: 3, reload_page: page, max_attempts: 20, exit_on_failure: true) do + QA::Support::Retrier.retry_until(sleep_interval: 3, reload_page: page, max_attempts: 20, raise_on_failure: true) do page.has_text? 'Welcome to Jenkins!' end end diff --git a/qa/qa/vendor/jenkins/page/new_credentials.rb b/qa/qa/vendor/jenkins/page/new_credentials.rb index bdef1a13fd45cc9c1a75b8dbe26747b3fb9cac99..b0d13973090c30e78ceea531c93dc72b18e9636f 100644 --- a/qa/qa/vendor/jenkins/page/new_credentials.rb +++ b/qa/qa/vendor/jenkins/page/new_credentials.rb @@ -39,7 +39,7 @@ module QA end def wait_for_page_to_load - QA::Support::Waiter.wait(interval: 1.0) do + QA::Support::Waiter.wait_until(sleep_interval: 1.0) do page.has_css?('.setting-name', text: "Description") end end diff --git a/qa/qa/vendor/one_password/cli.rb b/qa/qa/vendor/one_password/cli.rb index 3cb69391783559768eebaa28a72d13e995d56a85..cf8b7f8a4f9a74abb5f22e43509613cb683316be 100644 --- a/qa/qa/vendor/one_password/cli.rb +++ b/qa/qa/vendor/one_password/cli.rb @@ -1,9 +1,13 @@ # frozen_string_literal: true +require 'benchmark' + module QA module Vendor module OnePassword class CLI + include Singleton + def initialize @email = QA::Runtime::Env.gitlab_qa_1p_email @password = QA::Runtime::Env.gitlab_qa_1p_password @@ -11,14 +15,39 @@ module QA @github_uuid = QA::Runtime::Env.gitlab_qa_1p_github_uuid end - def otp - `#{op_path} get totp #{@github_uuid} --session=#{session_token}`.to_i + def fresh_otp + otps = [] + + # Fetches a fresh OTP and returns it only after op provides the same OTP twice + # An OTP is valid for 30 seconds so 70 attempts with 0.5 interval would ensure we complete 1 cycle + Support::Retrier.retry_until(max_attempts: 70, sleep_interval: 0.5) do + otps << fetch_otp + otps.size >= 3 && otps[-1] == otps[-2] && otps[-1] != otps[-3] + end + + otps.last end private + def fetch_otp + result = nil + + time = Benchmark.realtime do + result = `#{op_path} get totp #{@github_uuid} --session=#{session_token}`.to_i + end + + QA::Runtime::Logger.info("Fetched OTP: #{result} in: #{time} seconds") + + result + end + + # OP session tokens are valid for 30 minutes. We are caching the session token here and this is fine currently + # as we just have one test that is not expected to go over 30 minutes. + # But note that if we add more tests that use this class, we might need to add a mechanism to invalidate + # the cache after 30 minutes or if the session_token is rejected by op CLI. def session_token - `echo '#{@password}' | #{op_path} signin gitlab.1password.com #{@email} #{@secret} --output=raw --shorthand=gitlab_qa` + @session_token ||= `echo '#{@password}' | #{op_path} signin gitlab.1password.com #{@email} #{@secret} --output=raw --shorthand=gitlab_qa` end def op_path diff --git a/qa/qa/vendor/one_password/darwin/op b/qa/qa/vendor/one_password/darwin/op index 0f646522834582ed63b7b2794aa8ec982823b1a9..be7a3721b140423400e17df59954f4d167ab82d9 100755 Binary files a/qa/qa/vendor/one_password/darwin/op and b/qa/qa/vendor/one_password/darwin/op differ diff --git a/qa/qa/vendor/one_password/linux/op b/qa/qa/vendor/one_password/linux/op index 47ce87731be20e118990344e0c720db248cb7d78..47e79d7c599349df1696226a1df6570377a418d4 100755 Binary files a/qa/qa/vendor/one_password/linux/op and b/qa/qa/vendor/one_password/linux/op differ diff --git a/qa/spec/page/base_spec.rb b/qa/spec/page/base_spec.rb index 9e3f143ea5b0a5ed0b754f6aa47b684ca51cb046..2d13889d26df753a2214e19ba7c5c637bf35c240 100644 --- a/qa/spec/page/base_spec.rb +++ b/qa/spec/page/base_spec.rb @@ -3,7 +3,7 @@ describe QA::Page::Base do describe 'page helpers' do it 'exposes helpful page helpers' do - expect(subject).to respond_to :refresh, :wait, :scroll_to + expect(subject).to respond_to :refresh, :wait_until, :scroll_to end end @@ -62,18 +62,18 @@ describe QA::Page::Base do end end - describe '#wait' do + describe '#wait_until' do subject { Class.new(described_class).new } context 'when the condition is true' do it 'does not refresh' do expect(subject).not_to receive(:refresh) - subject.wait(max: 0.01) { true } + subject.wait_until(max_duration: 0.01, raise_on_failure: false) { true } end it 'returns true' do - expect(subject.wait(max: 0.1) { true }).to be_truthy + expect(subject.wait_until(max_duration: 0.1, raise_on_failure: false) { true }).to be_truthy end end @@ -81,13 +81,29 @@ describe QA::Page::Base do it 'refreshes' do expect(subject).to receive(:refresh).at_least(:once) - subject.wait(max: 0.01) { false } + subject.wait_until(max_duration: 0.01, raise_on_failure: false) { false } end it 'returns false' do allow(subject).to receive(:refresh) - expect(subject.wait(max: 0.01) { false }).to be_falsey + expect(subject.wait_until(max_duration: 0.01, raise_on_failure: false) { false }).to be_falsey + end + end + end + + describe '#all_elements' do + before do + allow(subject).to receive(:all) + end + + it 'raises an error if count or minimum are not specified' do + expect { subject.all_elements(:foo) }.to raise_error ArgumentError + end + + it 'does not raise an error if :minimum, :maximum, :count, or :between is specified' do + [:minimum, :maximum, :count, :between].each do |param| + expect { subject.all_elements(:foo, param => 1) }.not_to raise_error end end end diff --git a/qa/spec/page/logging_spec.rb b/qa/spec/page/logging_spec.rb index fb89bcd3ab498a5e55b94137be96c32d347e7e2d..a6b61e9b1ee9d735d9c19438feb51daaa31ca03a 100644 --- a/qa/spec/page/logging_spec.rb +++ b/qa/spec/page/logging_spec.rb @@ -28,21 +28,21 @@ describe QA::Support::Page::Logging do end it 'logs wait' do - expect { subject.wait(max: 0) {} } + expect { subject.wait_until(max_duration: 0) {} } .to output(/next wait uses reload: true/).to_stdout_from_any_process - expect { subject.wait(max: 0) {} } - .to output(/with wait/).to_stdout_from_any_process - expect { subject.wait(max: 0) {} } - .to output(/ended wait after .* seconds$/).to_stdout_from_any_process + expect { subject.wait_until(max_duration: 0) {} } + .to output(/with wait_until/).to_stdout_from_any_process + expect { subject.wait_until(max_duration: 0) {} } + .to output(/ended wait_until$/).to_stdout_from_any_process end it 'logs wait with reload false' do - expect { subject.wait(max: 0, reload: false) {} } + expect { subject.wait_until(max_duration: 0, reload: false) {} } .to output(/next wait uses reload: false/).to_stdout_from_any_process - expect { subject.wait(max: 0, reload: false) {} } - .to output(/with wait/).to_stdout_from_any_process - expect { subject.wait(max: 0, reload: false) {} } - .to output(/ended wait after .* seconds$/).to_stdout_from_any_process + expect { subject.wait_until(max_duration: 0, reload: false) {} } + .to output(/with wait_until/).to_stdout_from_any_process + expect { subject.wait_until(max_duration: 0, reload: false) {} } + .to output(/ended wait_until$/).to_stdout_from_any_process end it 'logs scroll_to' do @@ -121,10 +121,10 @@ describe QA::Support::Page::Logging do end it 'logs has_no_text?' do - allow(page).to receive(:has_no_text?).with('foo').and_return(true) + allow(page).to receive(:has_no_text?).with('foo', any_args).and_return(true) expect { subject.has_no_text? 'foo' } - .to output(/has_no_text\?\('foo'\) returned true/).to_stdout_from_any_process + .to output(/has_no_text\?\('foo', wait: #{QA::Runtime::Browser::CAPYBARA_MAX_WAIT_TIME}\) returned true/).to_stdout_from_any_process end it 'logs finished_loading?' do @@ -145,18 +145,18 @@ describe QA::Support::Page::Logging do it 'logs the number of elements found' do allow(page).to receive(:all).and_return([1, 2]) - expect { subject.all_elements(:element) } + expect { subject.all_elements(:element, count: 2) } .to output(/finding all :element/).to_stdout_from_any_process - expect { subject.all_elements(:element) } + expect { subject.all_elements(:element, count: 2) } .to output(/found 2 :element/).to_stdout_from_any_process end it 'logs 0 if no elements are found' do allow(page).to receive(:all).and_return([]) - expect { subject.all_elements(:element) } + expect { subject.all_elements(:element, count: 1) } .to output(/finding all :element/).to_stdout_from_any_process - expect { subject.all_elements(:element) } + expect { subject.all_elements(:element, count: 1) } .not_to output(/found 0 :elements/).to_stdout_from_any_process end end diff --git a/qa/spec/resource/events/project_spec.rb b/qa/spec/resource/events/project_spec.rb index b3efdb518f338d7d942dd2b2e8bd158022b7c450..dd544ec7ac8a1ca746997b9da996578ef9d3afe4 100644 --- a/qa/spec/resource/events/project_spec.rb +++ b/qa/spec/resource/events/project_spec.rb @@ -33,6 +33,7 @@ describe QA::Resource::Events::Project do before do allow(subject).to receive(:max_wait).and_return(0.01) + allow(subject).to receive(:raise_on_failure).and_return(false) allow(subject).to receive(:parse_body).and_return(all_events) end diff --git a/qa/spec/runtime/application_settings_spec.rb b/qa/spec/runtime/application_settings_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..fce0361aee0c17e3d45ecb7fdc1cb63122567223 --- /dev/null +++ b/qa/spec/runtime/application_settings_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +describe QA::Runtime::ApplicationSettings do + let(:api_client) { double('QA::Runtime::API::Client') } + let(:request) { Struct.new(:url).new('http://api') } + let(:get_response) { Struct.new(:body).new("{}") } + + before do + allow(described_class).to receive(:api_client).and_return(api_client) + end + + describe '.set_application_settings' do + it 'sets application settings' do + expect(QA::Runtime::API::Request) + .to receive(:new) + .with(api_client, '/application/settings') + .and_return(request) + + expect(described_class) + .to receive(:put) + .with(request.url, { allow_local_requests_from_web_hooks_and_services: true }) + .and_return(Struct.new(:code).new(200)) + + subject.set_application_settings(allow_local_requests_from_web_hooks_and_services: true) + end + end + + describe '.get_application_settings' do + it 'gets application settings' do + expect(QA::Runtime::API::Request) + .to receive(:new) + .with(api_client, '/application/settings') + .and_return(request) + + expect(described_class) + .to receive(:get) + .with(request.url) + .and_return(get_response) + + subject.get_application_settings + end + end +end diff --git a/qa/spec/runtime/env_spec.rb b/qa/spec/runtime/env_spec.rb index 340831aa06d60c3b4ce72cd8639fbe60379045f8..0a0bf33a7265f3932fb8aa18f004610bc17a7a27 100644 --- a/qa/spec/runtime/env_spec.rb +++ b/qa/spec/runtime/env_spec.rb @@ -230,6 +230,20 @@ describe QA::Runtime::Env do end end + describe '.require_admin_access_token!' do + it 'raises ArgumentError if GITLAB_QA_ADMIN_ACCESS_TOKEN is not specified' do + stub_env('GITLAB_QA_ADMIN_ACCESS_TOKEN', nil) + + expect { described_class.require_admin_access_token! }.to raise_error(ArgumentError) + end + + it 'does not raise exception if GITLAB_QA_ADMIN_ACCESS_TOKEN is specified' do + stub_env('GITLAB_QA_ADMIN_ACCESS_TOKEN', 'foobar123') + + expect { described_class.require_admin_access_token! }.not_to raise_error + end + end + describe '.log_destination' do it 'returns $stdout if QA_LOG_PATH is not defined' do stub_env('QA_LOG_PATH', nil) diff --git a/qa/spec/spec_helper.rb b/qa/spec/spec_helper.rb index 3a26ed89e9c88dce4a31e5f98e7ff176aee2694f..1336bea16bc5a31d54e22fbe9d03cedabd0991c3 100644 --- a/qa/spec/spec_helper.rb +++ b/qa/spec/spec_helper.rb @@ -12,9 +12,9 @@ QA::Runtime::Browser.configure! QA::Runtime::Scenario.from_env(QA::Runtime::Env.runtime_scenario_attributes) if QA::Runtime::Env.runtime_scenario_attributes -%w[helpers shared_examples].each do |d| - Dir[::File.join(__dir__, d, '**', '*.rb')].each { |f| require f } -end +Dir[::File.join(__dir__, "support/helpers/*.rb")].each { |f| require f } +Dir[::File.join(__dir__, "support/shared_contexts/*.rb")].each { |f| require f } +Dir[::File.join(__dir__, "support/shared_examples/*.rb")].each { |f| require f } RSpec.configure do |config| QA::Specs::Helpers::Quarantine.configure_rspec diff --git a/qa/spec/specs/helpers/quarantine_spec.rb b/qa/spec/specs/helpers/quarantine_spec.rb index 2538632c0327be3a80669298a99d7caa56d553e6..d5c6820f0a9b5f880342f5bdd8e5aeb524d7eae7 100644 --- a/qa/spec/specs/helpers/quarantine_spec.rb +++ b/qa/spec/specs/helpers/quarantine_spec.rb @@ -155,6 +155,26 @@ describe QA::Specs::Helpers::Quarantine do expect(group.examples.first.execution_result.status).to eq(:passed) end + + context 'quarantine message' do + shared_examples 'test with quarantine message' do |quarantine_tag| + it 'outputs the quarantine message' do + group = describe_successfully do + it('is quarantined', quarantine: quarantine_tag) {} + end + + expect(group.examples.first.execution_result.pending_message) + .to eq('In quarantine : for a reason') + end + end + + it_behaves_like 'test with quarantine message', 'for a reason' + + it_behaves_like 'test with quarantine message', { + issue: 'for a reason', + environment: [:nightly, :staging] + } + end end context 'with :quarantine focused' do diff --git a/qa/spec/helpers/stub_env.rb b/qa/spec/support/helpers/stub_env.rb similarity index 100% rename from qa/spec/helpers/stub_env.rb rename to qa/spec/support/helpers/stub_env.rb diff --git a/qa/spec/support/repeater_spec.rb b/qa/spec/support/repeater_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..20dca6608f6f983d311a1f9b6594b7b3de0e4d56 --- /dev/null +++ b/qa/spec/support/repeater_spec.rb @@ -0,0 +1,385 @@ +# frozen_string_literal: true + +require 'logger' +require 'timecop' +require 'active_support/core_ext/integer/time' + +describe QA::Support::Repeater do + before do + logger = ::Logger.new $stdout + logger.level = ::Logger::DEBUG + QA::Runtime::Logger.logger = logger + end + + subject do + Module.new do + extend QA::Support::Repeater + end + end + + let(:time_start) { Time.now } + let(:return_value) { "test passed" } + + describe '.repeat_until' do + context 'when raise_on_failure is not provided (default: true)' do + context 'when retry_on_exception is not provided (default: false)' do + context 'when max_duration is provided' do + context 'when max duration is reached' do + it 'raises an exception' do + expect do + Timecop.freeze do + subject.repeat_until(max_duration: 1) do + Timecop.travel(2) + false + end + end + end.to raise_error(QA::Support::Repeater::WaitExceededError, "Wait condition not met after 1 second") + end + + it 'ignores attempts' do + loop_counter = 0 + + expect( + Timecop.freeze do + subject.repeat_until(max_duration: 1) do + loop_counter += 1 + + if loop_counter > 3 + Timecop.travel(1) + return_value + else + false + end + end + end + ).to eq(return_value) + expect(loop_counter).to eq(4) + end + end + + context 'when max duration is not reached' do + it 'returns value from block' do + Timecop.freeze(time_start) do + expect( + subject.repeat_until(max_duration: 1) do + return_value + end + ).to eq(return_value) + end + end + end + end + + context 'when max_attempts is provided' do + context 'when max_attempts is reached' do + it 'raises an exception' do + expect do + Timecop.freeze do + subject.repeat_until(max_attempts: 1) do + false + end + end + end.to raise_error(QA::Support::Repeater::RetriesExceededError, "Retry condition not met after 1 attempt") + end + + it 'ignores duration' do + loop_counter = 0 + + expect( + Timecop.freeze do + subject.repeat_until(max_attempts: 2) do + loop_counter += 1 + Timecop.travel(1.year) + + if loop_counter > 1 + return_value + else + false + end + end + end + ).to eq(return_value) + expect(loop_counter).to eq(2) + end + end + + context 'when max_attempts is not reached' do + it 'returns value from block' do + expect( + Timecop.freeze do + subject.repeat_until(max_attempts: 1) do + return_value + end + end + ).to eq(return_value) + end + end + end + + context 'when both max_attempts and max_duration are provided' do + context 'when max_attempts is reached first' do + it 'raises an exception' do + loop_counter = 0 + expect do + Timecop.freeze do + subject.repeat_until(max_attempts: 1, max_duration: 2) do + loop_counter += 1 + Timecop.travel(time_start + loop_counter) + false + end + end + end.to raise_error(QA::Support::Repeater::RetriesExceededError, "Retry condition not met after 1 attempt") + end + end + + context 'when max_duration is reached first' do + it 'raises an exception' do + loop_counter = 0 + expect do + Timecop.freeze do + subject.repeat_until(max_attempts: 2, max_duration: 1) do + loop_counter += 1 + Timecop.travel(time_start + loop_counter) + false + end + end + end.to raise_error(QA::Support::Repeater::WaitExceededError, "Wait condition not met after 1 second") + end + end + end + end + + context 'when retry_on_exception is true' do + context 'when max duration is reached' do + it 'raises an exception' do + Timecop.freeze do + expect do + subject.repeat_until(max_duration: 1, retry_on_exception: true) do + Timecop.travel(2) + + raise "this should be raised" + end + end.to raise_error(RuntimeError, "this should be raised") + end + end + + it 'does not raise an exception until max_duration is reached' do + loop_counter = 0 + + Timecop.freeze(time_start) do + expect do + subject.repeat_until(max_duration: 2, retry_on_exception: true) do + loop_counter += 1 + Timecop.travel(time_start + loop_counter) + + raise "this should be raised" + end + end.to raise_error(RuntimeError, "this should be raised") + end + expect(loop_counter).to eq(2) + end + end + + context 'when max duration is not reached' do + it 'returns value from block' do + loop_counter = 0 + + Timecop.freeze(time_start) do + expect( + subject.repeat_until(max_duration: 3, retry_on_exception: true) do + loop_counter += 1 + Timecop.travel(time_start + loop_counter) + + raise "this should not be raised" if loop_counter == 1 + + return_value + end + ).to eq(return_value) + end + expect(loop_counter).to eq(2) + end + end + + context 'when both max_attempts and max_duration are provided' do + context 'when max_attempts is reached first' do + it 'raises an exception' do + loop_counter = 0 + expect do + Timecop.freeze do + subject.repeat_until(max_attempts: 1, max_duration: 2, retry_on_exception: true) do + loop_counter += 1 + Timecop.travel(time_start + loop_counter) + false + end + end + end.to raise_error(QA::Support::Repeater::RetriesExceededError, "Retry condition not met after 1 attempt") + end + end + + context 'when max_duration is reached first' do + it 'raises an exception' do + loop_counter = 0 + expect do + Timecop.freeze do + subject.repeat_until(max_attempts: 2, max_duration: 1, retry_on_exception: true) do + loop_counter += 1 + Timecop.travel(time_start + loop_counter) + false + end + end + end.to raise_error(QA::Support::Repeater::WaitExceededError, "Wait condition not met after 1 second") + end + end + end + end + end + + context 'when raise_on_failure is false' do + context 'when retry_on_exception is not provided (default: false)' do + context 'when max duration is reached' do + def test_wait + Timecop.freeze do + subject.repeat_until(max_duration: 1, raise_on_failure: false) do + Timecop.travel(2) + return_value + end + end + end + + it 'does not raise an exception' do + expect { test_wait }.not_to raise_error + end + + it 'returns the value from the block' do + expect(test_wait).to eq(return_value) + end + end + + context 'when max duration is not reached' do + it 'returns the value from the block' do + Timecop.freeze do + expect( + subject.repeat_until(max_duration: 1, raise_on_failure: false) do + return_value + end + ).to eq(return_value) + end + end + + it 'raises an exception' do + Timecop.freeze do + expect do + subject.repeat_until(max_duration: 1, raise_on_failure: false) do + raise "this should be raised" + end + end.to raise_error(RuntimeError, "this should be raised") + end + end + end + + context 'when both max_attempts and max_duration are provided' do + shared_examples 'repeat until' do |max_attempts:, max_duration:| + it "returns when #{max_attempts < max_duration ? 'max_attempts' : 'max_duration'} is reached" do + loop_counter = 0 + + expect( + Timecop.freeze do + subject.repeat_until(max_attempts: max_attempts, max_duration: max_duration, raise_on_failure: false) do + loop_counter += 1 + Timecop.travel(time_start + loop_counter) + false + end + end + ).to eq(false) + expect(loop_counter).to eq(1) + end + end + + context 'when max_attempts is reached first' do + it_behaves_like 'repeat until', max_attempts: 1, max_duration: 2 + end + + context 'when max_duration is reached first' do + it_behaves_like 'repeat until', max_attempts: 2, max_duration: 1 + end + end + end + + context 'when retry_on_exception is true' do + context 'when max duration is reached' do + def test_wait + Timecop.freeze do + subject.repeat_until(max_duration: 1, raise_on_failure: false, retry_on_exception: true) do + Timecop.travel(2) + return_value + end + end + end + + it 'does not raise an exception' do + expect { test_wait }.not_to raise_error + end + + it 'returns the value from the block' do + expect(test_wait).to eq(return_value) + end + end + + context 'when max duration is not reached' do + before do + @loop_counter = 0 + end + + def test_wait_with_counter + Timecop.freeze(time_start) do + subject.repeat_until(max_duration: 3, raise_on_failure: false, retry_on_exception: true) do + @loop_counter += 1 + Timecop.travel(time_start + @loop_counter) + + raise "this should not be raised" if @loop_counter == 1 + + return_value + end + end + end + + it 'does not raise an exception' do + expect { test_wait_with_counter }.not_to raise_error + end + + it 'returns the value from the block' do + expect(test_wait_with_counter).to eq(return_value) + expect(@loop_counter).to eq(2) + end + end + + context 'when both max_attempts and max_duration are provided' do + shared_examples 'repeat until' do |max_attempts:, max_duration:| + it "returns when #{max_attempts < max_duration ? 'max_attempts' : 'max_duration'} is reached" do + loop_counter = 0 + + expect( + Timecop.freeze do + subject.repeat_until(max_attempts: max_attempts, max_duration: max_duration, raise_on_failure: false, retry_on_exception: true) do + loop_counter += 1 + Timecop.travel(time_start + loop_counter) + false + end + end + ).to eq(false) + expect(loop_counter).to eq(1) + end + end + + context 'when max_attempts is reached first' do + it_behaves_like 'repeat until', max_attempts: 1, max_duration: 2 + end + + context 'when max_duration is reached first' do + it_behaves_like 'repeat until', max_attempts: 2, max_duration: 1 + end + end + end + end + end +end diff --git a/qa/spec/support/retrier_spec.rb b/qa/spec/support/retrier_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..fbe66a680f91a8aec2a9446e8cfe3fa9a301929c --- /dev/null +++ b/qa/spec/support/retrier_spec.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +require 'logger' +require 'timecop' + +describe QA::Support::Retrier do + before do + logger = ::Logger.new $stdout + logger.level = ::Logger::DEBUG + QA::Runtime::Logger.logger = logger + end + + describe '.retry_until' do + context 'when the condition is true' do + it 'logs max attempts (3 by default)' do + expect { subject.retry_until { true } } + .to output(/with retry_until: max_attempts: 3; reload_page: ; sleep_interval: 0; raise_on_failure: false; retry_on_exception: false/).to_stdout_from_any_process + end + + it 'logs max duration' do + expect { subject.retry_until(max_duration: 1) { true } } + .to output(/with retry_until: max_duration: 1; reload_page: ; sleep_interval: 0; raise_on_failure: false; retry_on_exception: false/).to_stdout_from_any_process + end + + it 'logs the end' do + expect { subject.retry_until { true } } + .to output(/ended retry_until$/).to_stdout_from_any_process + end + end + + context 'when the condition is false' do + it 'logs the start' do + expect { subject.retry_until(max_duration: 0) { false } } + .to output(/with retry_until: max_duration: 0; reload_page: ; sleep_interval: 0; raise_on_failure: false; retry_on_exception: false/).to_stdout_from_any_process + end + + it 'logs the end' do + expect { subject.retry_until(max_duration: 0) { false } } + .to output(/ended retry_until$/).to_stdout_from_any_process + end + end + + context 'when max_duration and max_attempts are nil' do + it 'sets max attempts to 3 by default' do + expect(subject).to receive(:repeat_until).with(hash_including(max_attempts: 3)) + + subject.retry_until + end + end + + it 'sets sleep_interval to 0 by default' do + expect(subject).to receive(:repeat_until).with(hash_including(sleep_interval: 0)) + + subject.retry_until + end + + it 'sets raise_on_failure to false by default' do + expect(subject).to receive(:repeat_until).with(hash_including(raise_on_failure: false)) + + subject.retry_until + end + + it 'sets retry_on_exception to false by default' do + expect(subject).to receive(:repeat_until).with(hash_including(retry_on_exception: false)) + + subject.retry_until + end + end + + describe '.retry_on_exception' do + context 'when the condition is true' do + it 'logs max_attempts, reload_page, and sleep_interval parameters' do + expect { subject.retry_on_exception(max_attempts: 1, reload_page: nil, sleep_interval: 0) { true } } + .to output(/with retry_on_exception: max_attempts: 1; reload_page: ; sleep_interval: 0/).to_stdout_from_any_process + end + + it 'logs the end' do + expect { subject.retry_on_exception(max_attempts: 1, reload_page: nil, sleep_interval: 0) { true } } + .to output(/ended retry_on_exception$/).to_stdout_from_any_process + end + end + + context 'when the condition is false' do + it 'logs the start' do + expect { subject.retry_on_exception(max_attempts: 1, reload_page: nil, sleep_interval: 0) { false } } + .to output(/with retry_on_exception: max_attempts: 1; reload_page: ; sleep_interval: 0/).to_stdout_from_any_process + end + + it 'logs the end' do + expect { subject.retry_on_exception(max_attempts: 1, reload_page: nil, sleep_interval: 0) { false } } + .to output(/ended retry_on_exception$/).to_stdout_from_any_process + end + end + + it 'does not repeat if no exception is raised' do + loop_counter = 0 + return_value = "test passed" + + expect( + subject.retry_on_exception(max_attempts: 2) do + loop_counter += 1 + return_value + end + ).to eq(return_value) + expect(loop_counter).to eq(1) + end + + it 'sets retry_on_exception to true' do + expect(subject).to receive(:repeat_until).with(hash_including(retry_on_exception: true)) + + subject.retry_on_exception + end + + it 'sets max_attempts to 3 by default' do + expect(subject).to receive(:repeat_until).with(hash_including(max_attempts: 3)) + + subject.retry_on_exception + end + + it 'sets sleep_interval to 0.5 by default' do + expect(subject).to receive(:repeat_until).with(hash_including(sleep_interval: 0.5)) + + subject.retry_on_exception + end + end +end diff --git a/qa/spec/shared_examples/scenario_shared_examples.rb b/qa/spec/support/shared_examples/scenario_shared_examples.rb similarity index 93% rename from qa/spec/shared_examples/scenario_shared_examples.rb rename to qa/spec/support/shared_examples/scenario_shared_examples.rb index 697e6cb39c828ab1b9cc0f9356cbe1770c606b5b..17469ea470c32c4219a7e200f432223d3062e0c7 100644 --- a/qa/spec/shared_examples/scenario_shared_examples.rb +++ b/qa/spec/support/shared_examples/scenario_shared_examples.rb @@ -31,12 +31,6 @@ shared_examples 'a QA scenario class' do expect(attributes).to have_received(:define).with(:gitlab_address, 'http://gitlab_address').at_least(:once) end - it 'performs before hooks' do - subject.perform(args) - - expect(release).to have_received(:perform_before_hooks) - end - it 'sets tags on runner' do subject.perform(args) diff --git a/qa/spec/support/waiter_spec.rb b/qa/spec/support/waiter_spec.rb index 8283b65e1beea6624f9d48dc191e23f5aa908189..06e404c862a45c7e240b6aa95d307c527005ac40 100644 --- a/qa/spec/support/waiter_spec.rb +++ b/qa/spec/support/waiter_spec.rb @@ -9,29 +9,53 @@ describe QA::Support::Waiter do QA::Runtime::Logger.logger = logger end - describe '.wait' do + describe '.wait_until' do context 'when the condition is true' do it 'logs the start' do - expect { subject.wait(max: 0) {} } - .to output(/with wait: max 0; interval 0.1/).to_stdout_from_any_process + expect { subject.wait_until(max_duration: 0, raise_on_failure: false) { true } } + .to output(/with wait_until: max_duration: 0; reload_page: ; sleep_interval: 0.1/).to_stdout_from_any_process end it 'logs the end' do - expect { subject.wait(max: 0) {} } - .to output(/ended wait after .* seconds$/).to_stdout_from_any_process + expect { subject.wait_until(max_duration: 0, raise_on_failure: false) { true } } + .to output(/ended wait_until$/).to_stdout_from_any_process end end context 'when the condition is false' do it 'logs the start' do - expect { subject.wait(max: 0) { false } } - .to output(/with wait: max 0; interval 0.1/).to_stdout_from_any_process + expect { subject.wait_until(max_duration: 0, raise_on_failure: false) { false } } + .to output(/with wait_until: max_duration: 0; reload_page: ; sleep_interval: 0.1/).to_stdout_from_any_process end it 'logs the end' do - expect { subject.wait(max: 0) { false } } - .to output(/ended wait after .* seconds$/).to_stdout_from_any_process + expect { subject.wait_until(max_duration: 0, raise_on_failure: false) { false } } + .to output(/ended wait_until$/).to_stdout_from_any_process end end + + it 'sets max_duration to 60 by default' do + expect(subject).to receive(:repeat_until).with(hash_including(max_duration: 60)) + + subject.wait_until + end + + it 'sets sleep_interval to 0.1 by default' do + expect(subject).to receive(:repeat_until).with(hash_including(sleep_interval: 0.1)) + + subject.wait_until + end + + it 'sets raise_on_failure to false by default' do + expect(subject).to receive(:repeat_until).with(hash_including(raise_on_failure: false)) + + subject.wait_until + end + + it 'sets retry_on_exception to false by default' do + expect(subject).to receive(:repeat_until).with(hash_including(retry_on_exception: false)) + + subject.wait_until + end end end diff --git a/rubocop/cop/migration/add_column_with_default.rb b/rubocop/cop/migration/add_column_with_default.rb new file mode 100644 index 0000000000000000000000000000000000000000..d9f8fe62a86c369186b2fd58ff8ea33aedaf80dc --- /dev/null +++ b/rubocop/cop/migration/add_column_with_default.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require_relative '../../migration_helpers' + +module RuboCop + module Cop + module Migration + # Cop that checks if columns are added in a way that doesn't require + # downtime. + class AddColumnWithDefault < RuboCop::Cop::Cop + include MigrationHelpers + + WHITELISTED_TABLES = [:application_settings].freeze + + MSG = '`add_column_with_default` without `allow_null: true` may cause prolonged lock situations and downtime, ' \ + 'see https://gitlab.com/gitlab-org/gitlab/issues/38060'.freeze + + def_node_matcher :add_column_with_default?, <<~PATTERN + (send _ :add_column_with_default $_ ... (hash $...)) + PATTERN + + def on_send(node) + return unless in_migration?(node) + + add_column_with_default?(node) do |table, options| + break if table_whitelisted?(table) || nulls_allowed?(options) + + add_offense(node, location: :selector) + end + end + + private + + def nulls_allowed?(options) + options.find { |opt| opt.key.value == :allow_null && opt.value.true_type? } + end + + def table_whitelisted?(symbol) + symbol && symbol.type == :sym && + WHITELISTED_TABLES.include?(symbol.children[0]) + end + end + end + end +end diff --git a/rubocop/cop/rspec/have_gitlab_http_status.rb b/rubocop/cop/rspec/have_gitlab_http_status.rb new file mode 100644 index 0000000000000000000000000000000000000000..6b1797200603bd1c12c243d54b8465d0ecf0e805 --- /dev/null +++ b/rubocop/cop/rspec/have_gitlab_http_status.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +require 'rack/utils' + +module RuboCop + module Cop + module RSpec + # This cops checks for `have_http_status` usages in specs. + # It also discourages the usage of numeric HTTP status codes in + # `have_gitlab_http_status`. + # + # @example + # + # # bad + # expect(response).to have_http_status(200) + # expect(response).to have_http_status(:ok) + # expect(response).to have_gitlab_http_status(200) + # + # # good + # expect(response).to have_gitlab_http_status(:ok) + # + class HaveGitlabHttpStatus < RuboCop::Cop::Cop + CODE_TO_SYMBOL = Rack::Utils::SYMBOL_TO_STATUS_CODE.invert + + MSG_MATCHER_NAME = + 'Use `have_gitlab_http_status` instead of `have_http_status`.' + + MSG_STATUS = + 'Prefer named HTTP status `%{name}` over ' \ + 'its numeric representation `%{code}`.' + + MSG_UNKNOWN = 'HTTP status `%{code}` is unknown. ' \ + 'Please provide a valid one or disable this cop.' + + MSG_DOCS_LINK = 'https://docs.gitlab.com/ee/development/testing_guide/best_practices.html#have_gitlab_http_status' + + REPLACEMENT = 'have_gitlab_http_status(%{arg})' + + def_node_matcher :have_http_status?, <<~PATTERN + ( + send nil? + { + :have_http_status + :have_gitlab_http_status + } + _ + ) + PATTERN + + def on_send(node) + return unless have_http_status?(node) + + offenses = [ + offense_for_name(node), + offense_for_status(node) + ].compact + + return if offenses.empty? + + add_offense(node, message: message_for(offenses)) + end + + def autocorrect(node) + lambda do |corrector| + corrector.replace(node.source_range, replacement(node)) + end + end + + private + + def offense_for_name(node) + return if method_name(node) == :have_gitlab_http_status + + MSG_MATCHER_NAME + end + + def offense_for_status(node) + code = extract_numeric_code(node) + return unless code + + symbol = code_to_symbol(code) + return format(MSG_UNKNOWN, code: code) unless symbol + + format(MSG_STATUS, name: symbol, code: code) + end + + def message_for(offenses) + (offenses + [MSG_DOCS_LINK]).join(' ') + end + + def replacement(node) + code = extract_numeric_code(node) + arg = code_to_symbol(code) || argument(node).source + + format(REPLACEMENT, arg: arg) + end + + def code_to_symbol(code) + CODE_TO_SYMBOL[code]&.inspect + end + + def extract_numeric_code(node) + arg_node = argument(node) + return unless arg_node&.type == :int + + arg_node.children[0] + end + + def method_name(node) + node.children[1] + end + + def argument(node) + node.children[2] + end + end + end + end +end diff --git a/rubocop/rubocop.rb b/rubocop/rubocop.rb index 1465c73d570252b4f8ef37bb4ecbc259768e63b4..1479dc3384a461935957037264596cf58bd6cbc9 100644 --- a/rubocop/rubocop.rb +++ b/rubocop/rubocop.rb @@ -17,6 +17,7 @@ require_relative 'cop/prefer_class_methods_over_module' require_relative 'cop/put_project_routes_under_scope' require_relative 'cop/put_group_routes_under_scope' require_relative 'cop/migration/add_column' +require_relative 'cop/migration/add_column_with_default' require_relative 'cop/migration/add_concurrent_foreign_key' require_relative 'cop/migration/add_concurrent_index' require_relative 'cop/migration/add_index' @@ -39,6 +40,7 @@ require_relative 'cop/rspec/be_success_matcher' require_relative 'cop/rspec/env_assignment' require_relative 'cop/rspec/factories_in_migration_specs' require_relative 'cop/rspec/top_level_describe_path' +require_relative 'cop/rspec/have_gitlab_http_status' require_relative 'cop/qa/element_with_pattern' require_relative 'cop/qa/ambiguous_page_object_name' require_relative 'cop/sidekiq_options_queue' diff --git a/scripts/insert-rspec-profiling-data b/scripts/insert-rspec-profiling-data index 88c9d8c12b1267488d34ddcc4885091800082548..3af5fe763a2b5235b873215711ad9b2b9ae874c3 100755 --- a/scripts/insert-rspec-profiling-data +++ b/scripts/insert-rspec-profiling-data @@ -35,6 +35,8 @@ def insert_data(path) files.each do |filename| puts "#{Time.now} Inserting #{filename}..." + # Strip file of NULL bytes to ensure data gets inserted + system("sed", "-i", "-e", "s/\\x00//g", filename) result = RspecProfiling::Collectors::PSQL::Result.copy_from(filename) puts "#{Time.now} Inserted #{result.cmd_tuples} lines in #{filename}, DB response: #{result.cmd_status}" end diff --git a/scripts/lint-changelog-filenames b/scripts/lint-changelog-filenames new file mode 100755 index 0000000000000000000000000000000000000000..2355ac6f7b227712a55cf8855925342e8e403f97 --- /dev/null +++ b/scripts/lint-changelog-filenames @@ -0,0 +1,12 @@ +#!/bin/sh + +lint_paths="changelogs/unreleased" +[ -d "ee/" ] && lint_paths="$lint_paths ee/changelogs/unreleased" + +invalid_files=$(find $lint_paths -type f -not -name "*.yml" -not -name ".gitkeep") + +if [ -n "$invalid_files" ]; then + echo "Changelog files must end in .yml, but these did not:" + echo "$invalid_files" | sed -e "s/^/* /" + exit 1 +fi diff --git a/scripts/notifications.sh b/scripts/notifications.sh deleted file mode 100755 index d1b11d44e88098b548900f82f04ad0276edb2697..0000000000000000000000000000000000000000 --- a/scripts/notifications.sh +++ /dev/null @@ -1,27 +0,0 @@ -# Sends Slack notification MSG to CI_SLACK_WEBHOOK_URL (which needs to be set). -# ICON_EMOJI needs to be set to an icon emoji name (without the `:` around it). -function notify_slack() { - CHANNEL=$1 - MSG=$2 - ICON_EMOJI=$3 - - if [ -z "$CHANNEL" ] || [ -z "$CI_SLACK_WEBHOOK_URL" ] || [ -z "$MSG" ] || [ -z "$ICON_EMOJI" ]; then - echo "Missing argument(s) - Use: $0 channel message icon_emoji" - echo "and set CI_SLACK_WEBHOOK_URL environment variable." - else - curl -X POST --data-urlencode 'payload={"channel": "#'"${CHANNEL}"'", "username": "GitLab QA Bot", "text": "'"${MSG}"'", "icon_emoji": "'":${ICON_EMOJI}:"'"}' "${CI_SLACK_WEBHOOK_URL}" - fi -} - -function notify_on_job_failure() { - JOB_NAME=$1 - CHANNEL=$2 - MSG=$3 - ICON_EMOJI=$4 - - local job_id - job_id=$(scripts/get-job-id "$CI_PROJECT_ID" "$CI_PIPELINE_ID" "$JOB_NAME" -s failed) - if [ -n "${job_id}" ]; then - notify_slack "${CHANNEL}" "${MSG}" "${ICON_EMOJI}" - fi -} diff --git a/scripts/static-analysis b/scripts/static-analysis index c26c9a55bb14665bfc9ffbbf96a861af87d2350a..1f55c035ed1e2c04a154f27282fc7ee0fb63d50b 100755 --- a/scripts/static-analysis +++ b/scripts/static-analysis @@ -35,8 +35,7 @@ ALLOWED_WARNINGS = [ def warning_count(static_analysis) static_analysis.warned_results - .reject { |result| ALLOWED_WARNINGS.include?(result.stderr.strip) } - .count + .count { |result| !ALLOWED_WARNINGS.include?(result.stderr.strip) } end def jobs_to_run(node_index, node_total) @@ -49,7 +48,8 @@ def jobs_to_run(node_index, node_total) %w[bundle exec rubocop --parallel], %w[scripts/lint-conflicts.sh], %w[scripts/lint-rugged], - %w[scripts/frontend/check_no_partial_karma_jest.sh] + %w[scripts/frontend/check_no_partial_karma_jest.sh], + %w[scripts/lint-changelog-filenames] ] case node_total diff --git a/scripts/sync-stable-branch.sh b/scripts/sync-stable-branch.sh index b44bf26a151176377b5f64157117809fb3eddd69..5aaec323628fafb809eae4fc1df5fae1626b4815 100644 --- a/scripts/sync-stable-branch.sh +++ b/scripts/sync-stable-branch.sh @@ -7,34 +7,50 @@ set -e if [[ "$MERGE_TRAIN_TRIGGER_TOKEN" == '' ]] then - echo 'The variable MERGE_TRAIN_TRIGGER_TOKEN must be set to a non-empy value' + echo 'The variable MERGE_TRAIN_TRIGGER_TOKEN must be set to a non-empty value' exit 1 fi if [[ "$MERGE_TRAIN_TRIGGER_URL" == '' ]] then - echo 'The variable MERGE_TRAIN_TRIGGER_URL must be set to a non-empy value' + echo 'The variable MERGE_TRAIN_TRIGGER_URL must be set to a non-empty value' exit 1 fi if [[ "$CI_COMMIT_REF_NAME" == '' ]] then - echo 'The variable CI_COMMIT_REF_NAME must be set to a non-empy value' + echo 'The variable CI_COMMIT_REF_NAME must be set to a non-empty value' exit 1 fi if [[ "$SOURCE_PROJECT" == '' ]] then - echo 'The variable SOURCE_PROJECT must be set to a non-empy value' + echo 'The variable SOURCE_PROJECT must be set to a non-empty value' exit 1 fi if [[ "$TARGET_PROJECT" == '' ]] then - echo 'The variable TARGET_PROJECT must be set to a non-empy value' + echo 'The variable TARGET_PROJECT must be set to a non-empty value' exit 1 fi +if [[ "$TARGET_PROJECT" != "gitlab-org/gitlab-foss" ]] +then + echo 'This is a security FOSS merge train' + echo "Checking if $CI_COMMIT_SHA is available on canonical" + + gitlab_com_commit_status=$(curl -s "https://gitlab.com/api/v4/projects/278964/repository/commits/$CI_COMMIT_SHA" | jq -M .status) + + if [[ "$gitlab_com_commit_status" != "null" ]] + then + echo 'Commit available on canonical, skipping merge train' + exit 0 + fi + + echo 'Commit not available, triggering a merge train' +fi + curl -X POST \ -F token="$MERGE_TRAIN_TRIGGER_TOKEN" \ -F ref=master \ diff --git a/scripts/trigger-build b/scripts/trigger-build index 537b2692b2783047be634c1ca4ad59f6fddbc9e1..6e50d8907d854e7fce4e45a92619ae03ae510293 100755 --- a/scripts/trigger-build +++ b/scripts/trigger-build @@ -18,11 +18,16 @@ module Trigger class Base def invoke!(post_comment: false, downstream_job_name: nil) + pipeline_variables = variables + + puts "Triggering downstream pipeline on #{downstream_project_path}" + puts "with variables #{pipeline_variables}" + pipeline = Gitlab.run_trigger( downstream_project_path, trigger_token, ref, - variables) + pipeline_variables) puts "Triggered downstream pipeline: #{pipeline.web_url}\n" puts "Waiting for downstream pipeline status" @@ -85,7 +90,8 @@ module Trigger 'TRIGGER_SOURCE' => ENV['CI_JOB_URL'], 'TOP_UPSTREAM_SOURCE_PROJECT' => ENV['CI_PROJECT_PATH'], 'TOP_UPSTREAM_SOURCE_JOB' => ENV['CI_JOB_URL'], - 'TOP_UPSTREAM_SOURCE_SHA' => ENV['CI_COMMIT_SHA'] + 'TOP_UPSTREAM_SOURCE_SHA' => ENV['CI_COMMIT_SHA'], + 'TOP_UPSTREAM_SOURCE_REF' => ENV['CI_COMMIT_REF_NAME'] } end diff --git a/spec/controllers/admin/sessions_controller_spec.rb b/spec/controllers/admin/sessions_controller_spec.rb index bd0bb0bd81f9fca277e9137d4b7a91b55a84e5c6..be996aee1d2785ce736ad5a6302e233e21f5fce0 100644 --- a/spec/controllers/admin/sessions_controller_spec.rb +++ b/spec/controllers/admin/sessions_controller_spec.rb @@ -122,7 +122,7 @@ describe Admin::SessionsController, :do_not_mock_admin_mode do describe '#destroy' do context 'for regular users' do it 'shows error page' do - get :destroy + post :destroy expect(response).to have_gitlab_http_status(404) expect(controller.current_user_mode.admin_mode?).to be(false) @@ -139,7 +139,7 @@ describe Admin::SessionsController, :do_not_mock_admin_mode do post :create, params: { password: user.password } expect(controller.current_user_mode.admin_mode?).to be(true) - get :destroy + post :destroy expect(response).to have_gitlab_http_status(:found) expect(response).to redirect_to(root_path) diff --git a/spec/controllers/admin/users_controller_spec.rb b/spec/controllers/admin/users_controller_spec.rb index f11b5e798c97e8e7b7885671044f08ec17079112..ebdfbe14dec08999670488558d9151aee4a30efb 100644 --- a/spec/controllers/admin/users_controller_spec.rb +++ b/spec/controllers/admin/users_controller_spec.rb @@ -4,6 +4,7 @@ require 'spec_helper' describe Admin::UsersController do let(:user) { create(:user) } + let_it_be(:admin) { create(:admin) } before do diff --git a/spec/controllers/application_controller_spec.rb b/spec/controllers/application_controller_spec.rb index e72ab16f62a7b08c451e2e6449629bb885700c3c..0c299dcda3484768d76426a6a3f5c05cef8f01c8 100644 --- a/spec/controllers/application_controller_spec.rb +++ b/spec/controllers/application_controller_spec.rb @@ -895,4 +895,50 @@ describe ApplicationController do end end end + + context '#set_current_context' do + controller(described_class) do + def index + Labkit::Context.with_context do |context| + render json: context.to_h + end + end + end + + let_it_be(:user) { create(:user) } + + before do + sign_in(user) + end + + it 'does not break anything when no group or project method is defined' do + get :index + + expect(response).to have_gitlab_http_status(:success) + end + + it 'sets the username in the context when signed in' do + get :index + + expect(json_response['meta.user']).to eq(user.username) + end + + it 'sets the group if it was available' do + group = build_stubbed(:group) + controller.instance_variable_set(:@group, group) + + get :index, format: :json + + expect(json_response['meta.root_namespace']).to eq(group.path) + end + + it 'sets the project if one was available' do + project = build_stubbed(:project) + controller.instance_variable_set(:@project, project) + + get :index, format: :json + + expect(json_response['meta.project']).to eq(project.full_path) + end + end end diff --git a/spec/controllers/autocomplete_controller_spec.rb b/spec/controllers/autocomplete_controller_spec.rb index 4227a4453a39e034ac881e6a69165f87f56996bb..51f20bae8804552fa48c66cc17685b7e07fb547e 100644 --- a/spec/controllers/autocomplete_controller_spec.rb +++ b/spec/controllers/autocomplete_controller_spec.rb @@ -112,9 +112,7 @@ describe AutocompleteController do context 'limited users per page' do before do - 25.times do - create(:user) - end + create_list(:user, 25) sign_in(user) get(:users) diff --git a/spec/controllers/concerns/confirm_email_warning_spec.rb b/spec/controllers/concerns/confirm_email_warning_spec.rb index 25429cdd149ed85cb3b145b6277225b78884e68a..56a6efab8ed1bbc288e0a5190beb3256ffd59097 100644 --- a/spec/controllers/concerns/confirm_email_warning_spec.rb +++ b/spec/controllers/concerns/confirm_email_warning_spec.rb @@ -10,7 +10,7 @@ describe ConfirmEmailWarning do controller(ApplicationController) do # `described_class` is not available in this context - include ConfirmEmailWarning # rubocop:disable RSpec/DescribedClass + include ConfirmEmailWarning def index head :ok diff --git a/spec/controllers/concerns/continue_params_spec.rb b/spec/controllers/concerns/continue_params_spec.rb index b4b62cbe1e37fde4dcfbb3b858a268032497976d..6af01aa837c5a73c2965202e835f806bbc99eeed 100644 --- a/spec/controllers/concerns/continue_params_spec.rb +++ b/spec/controllers/concerns/continue_params_spec.rb @@ -12,6 +12,7 @@ describe ContinueParams do end end end + subject(:controller) { controller_class.new } def strong_continue_params(params) diff --git a/spec/controllers/concerns/controller_with_cross_project_access_check_spec.rb b/spec/controllers/concerns/controller_with_cross_project_access_check_spec.rb index 7a56f7203b0aab1a0745b08f94f09e35a04ac57f..e47f1650b1f2a8b8f131f691d48634d931625a37 100644 --- a/spec/controllers/concerns/controller_with_cross_project_access_check_spec.rb +++ b/spec/controllers/concerns/controller_with_cross_project_access_check_spec.rb @@ -22,7 +22,7 @@ describe ControllerWithCrossProjectAccessCheck do describe '#requires_cross_project_access' do controller(ApplicationController) do # `described_class` is not available in this context - include ControllerWithCrossProjectAccessCheck # rubocop:disable RSpec/DescribedClass + include ControllerWithCrossProjectAccessCheck requires_cross_project_access :index, show: false, unless: -> { unless_condition }, @@ -81,7 +81,7 @@ describe ControllerWithCrossProjectAccessCheck do describe '#skip_cross_project_access_check' do controller(ApplicationController) do # `described_class` is not available in this context - include ControllerWithCrossProjectAccessCheck # rubocop:disable RSpec/DescribedClass + include ControllerWithCrossProjectAccessCheck requires_cross_project_access diff --git a/spec/controllers/concerns/group_tree_spec.rb b/spec/controllers/concerns/group_tree_spec.rb index 835c3d9b3af6cebaf9f0db306d35c8d86315bdca..543f0170be06345f1155d310d6f0271e727f4ce4 100644 --- a/spec/controllers/concerns/group_tree_spec.rb +++ b/spec/controllers/concerns/group_tree_spec.rb @@ -8,7 +8,7 @@ describe GroupTree do controller(ApplicationController) do # `described_class` is not available in this context - include GroupTree # rubocop:disable RSpec/DescribedClass + include GroupTree def index render_group_tree GroupsFinder.new(current_user).execute diff --git a/spec/controllers/concerns/internal_redirect_spec.rb b/spec/controllers/concerns/internal_redirect_spec.rb index e5e50cfd55eebe928b91d98f53617813dc8ad0da..cc6422f281770ab7f633d04f9d4f3939e370ba15 100644 --- a/spec/controllers/concerns/internal_redirect_spec.rb +++ b/spec/controllers/concerns/internal_redirect_spec.rb @@ -12,6 +12,7 @@ describe InternalRedirect do end end end + subject(:controller) { controller_class.new } describe '#safe_redirect_path' do diff --git a/spec/controllers/concerns/lfs_request_spec.rb b/spec/controllers/concerns/lfs_request_spec.rb index 823b9a504341f57f8cf0b538c97fc5af746b3ba4..584448e68f916bc4ed8945760a836ca0a893f634 100644 --- a/spec/controllers/concerns/lfs_request_spec.rb +++ b/spec/controllers/concerns/lfs_request_spec.rb @@ -7,7 +7,7 @@ describe LfsRequest do controller(Projects::GitHttpClientController) do # `described_class` is not available in this context - include LfsRequest # rubocop:disable RSpec/DescribedClass + include LfsRequest def show storage_project diff --git a/spec/controllers/concerns/metrics_dashboard_spec.rb b/spec/controllers/concerns/metrics_dashboard_spec.rb index ff2b6fbb8eca368e24f449d10e42cdbf05857cb3..389d264bed3481e021a3b5849b48814fd19972df 100644 --- a/spec/controllers/concerns/metrics_dashboard_spec.rb +++ b/spec/controllers/concerns/metrics_dashboard_spec.rb @@ -16,7 +16,7 @@ describe MetricsDashboard do end controller(::ApplicationController) do - include MetricsDashboard # rubocop:disable RSpec/DescribedClass + include MetricsDashboard end let(:json_response) do diff --git a/spec/controllers/concerns/renders_commits_spec.rb b/spec/controllers/concerns/renders_commits_spec.rb index 79350847383a1c250cef5cc04bda92b4188e3e97..c43ceb6b7953221b2820e85b97da2b9ead673c84 100644 --- a/spec/controllers/concerns/renders_commits_spec.rb +++ b/spec/controllers/concerns/renders_commits_spec.rb @@ -9,7 +9,7 @@ describe RendersCommits do controller(ApplicationController) do # `described_class` is not available in this context - include RendersCommits # rubocop:disable RSpec/DescribedClass + include RendersCommits def index @merge_request = MergeRequest.find(params[:id]) diff --git a/spec/controllers/concerns/routable_actions_spec.rb b/spec/controllers/concerns/routable_actions_spec.rb index 59d48c68b9cc9e0788d9aca1b25d6dbe5851cb85..a11f4d2a15439a3827ce5051594a6d3277264851 100644 --- a/spec/controllers/concerns/routable_actions_spec.rb +++ b/spec/controllers/concerns/routable_actions_spec.rb @@ -4,7 +4,7 @@ require 'spec_helper' describe RoutableActions do controller(::ApplicationController) do - include RoutableActions # rubocop:disable RSpec/DescribedClass + include RoutableActions before_action :routable diff --git a/spec/controllers/concerns/sourcegraph_gon_spec.rb b/spec/controllers/concerns/sourcegraph_decorator_spec.rb similarity index 96% rename from spec/controllers/concerns/sourcegraph_gon_spec.rb rename to spec/controllers/concerns/sourcegraph_decorator_spec.rb index 4fb7e37d148c8a6e063c42852c6286232d9728fe..f1f3f0489c603d2c2e62b7967505a9bf40143315 100644 --- a/spec/controllers/concerns/sourcegraph_gon_spec.rb +++ b/spec/controllers/concerns/sourcegraph_decorator_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -describe SourcegraphGon do +describe SourcegraphDecorator do let_it_be(:enabled_user) { create(:user, sourcegraph_enabled: true) } let_it_be(:disabled_user) { create(:user, sourcegraph_enabled: false) } let_it_be(:public_project) { create(:project, :public) } @@ -17,7 +17,7 @@ describe SourcegraphGon do let(:project) { internal_project } controller(ApplicationController) do - include SourcegraphGon # rubocop:disable RSpec/DescribedClass + include SourcegraphDecorator def index head :ok diff --git a/spec/controllers/concerns/static_object_external_storage_spec.rb b/spec/controllers/concerns/static_object_external_storage_spec.rb index 3a0219ddaa1fa419cb3211d98ef5fbd6c07d561c..ddd1a95427e39ea0efe9b634bdc361facff5e6d3 100644 --- a/spec/controllers/concerns/static_object_external_storage_spec.rb +++ b/spec/controllers/concerns/static_object_external_storage_spec.rb @@ -4,7 +4,7 @@ require 'spec_helper' describe StaticObjectExternalStorage do controller(Projects::ApplicationController) do - include StaticObjectExternalStorage # rubocop:disable RSpec/DescribedClass + include StaticObjectExternalStorage before_action :redirect_to_external_storage, if: :static_objects_external_storage_enabled? diff --git a/spec/controllers/groups/group_links_controller_spec.rb b/spec/controllers/groups/group_links_controller_spec.rb index 8f04822fee6d7746e3a075ff1dda1ceb5c828924..04f2e33b26a934c0827335697f815bd376d1fcb0 100644 --- a/spec/controllers/groups/group_links_controller_spec.rb +++ b/spec/controllers/groups/group_links_controller_spec.rb @@ -111,4 +111,100 @@ describe Groups::GroupLinksController do end end end + + describe '#update' do + let!(:link) do + create(:group_group_link, { shared_group: shared_group, + shared_with_group: shared_with_group }) + end + + let(:expiry_date) { 1.month.from_now.to_date } + + subject do + post(:update, params: { group_id: shared_group, + id: link.id, + group_link: { group_access: Gitlab::Access::GUEST, + expires_at: expiry_date } }) + end + + context 'when user has admin access to the shared group' do + before do + shared_group.add_owner(user) + end + + it 'updates existing link' do + expect(link.group_access).to eq(Gitlab::Access::DEVELOPER) + expect(link.expires_at).to be_nil + + subject + + link.reload + + expect(link.group_access).to eq(Gitlab::Access::GUEST) + expect(link.expires_at).to eq(expiry_date) + end + end + + context 'when user does not have admin access to the shared group' do + it 'renders 404' do + subject + + expect(response).to have_gitlab_http_status(404) + end + end + + context 'when feature flag is disabled' do + before do + stub_feature_flags(share_group_with_group: false) + end + + it 'renders 404' do + subject + + expect(response).to have_gitlab_http_status(404) + end + end + end + + describe '#destroy' do + let!(:link) do + create(:group_group_link, { shared_group: shared_group, + shared_with_group: shared_with_group }) + end + + subject do + post(:destroy, params: { group_id: shared_group, + id: link.id }) + end + + context 'when user has admin access to the shared group' do + before do + shared_group.add_owner(user) + end + + it 'deletes existing link' do + expect { subject }.to change(GroupGroupLink, :count).by(-1) + end + end + + context 'when user does not have admin access to the shared group' do + it 'renders 404' do + subject + + expect(response).to have_gitlab_http_status(404) + end + end + + context 'when feature flag is disabled' do + before do + stub_feature_flags(share_group_with_group: false) + end + + it 'renders 404' do + subject + + expect(response).to have_gitlab_http_status(404) + end + end + end end diff --git a/spec/controllers/groups/group_members_controller_spec.rb b/spec/controllers/groups/group_members_controller_spec.rb index a144d9e0786166b1f56f6f9da7376aeca8c08682..1c8a2bd160d45c29b540a548c6c5dee5e41ddf11 100644 --- a/spec/controllers/groups/group_members_controller_spec.rb +++ b/spec/controllers/groups/group_members_controller_spec.rb @@ -31,6 +31,12 @@ describe Groups::GroupMembersController do expect(assigns(:invited_members).map(&:invite_email)).to match_array(invited.map(&:invite_email)) end + it 'assigns skip groups' do + get :index, params: { group_id: group } + + expect(assigns(:skip_groups)).to match_array(group.related_group_ids) + end + it 'restricts search to one email' do get :index, params: { group_id: group, search_invited: invited.first.invite_email } diff --git a/spec/controllers/groups/milestones_controller_spec.rb b/spec/controllers/groups/milestones_controller_spec.rb index 4f4f9e5143be5a77739b3aecd27ccef5028cc85f..8fb9f0c516cf1447903d7f69dd0ae923d72ce630 100644 --- a/spec/controllers/groups/milestones_controller_spec.rb +++ b/spec/controllers/groups/milestones_controller_spec.rb @@ -148,6 +148,19 @@ describe Groups::MilestonesController do expect(response).to have_gitlab_http_status(200) expect(response.content_type).to eq 'application/json' end + + context 'for a subgroup' do + let(:subgroup) { create(:group, parent: group) } + + it 'includes ancestor group milestones' do + get :index, params: { group_id: subgroup.to_param }, format: :json + + milestones = json_response + + expect(milestones.count).to eq(1) + expect(milestones.first['title']).to eq('group milestone') + end + end end context 'external authorization' do diff --git a/spec/controllers/groups/uploads_controller_spec.rb b/spec/controllers/groups/uploads_controller_spec.rb index 60342bf8e3dcac5552776aa5cb8c19f7b17b0cfc..8abebd04e8bd570f3c28a89bf1c7588d4a01f2ab 100644 --- a/spec/controllers/groups/uploads_controller_spec.rb +++ b/spec/controllers/groups/uploads_controller_spec.rb @@ -19,6 +19,22 @@ describe Groups::UploadsController do let(:uploader_class) { NamespaceFileUploader } end + context 'with a moved group' do + let!(:upload) { create(:upload, :issuable_upload, :with_file, model: model) } + let(:group) { model } + let(:old_path) { group.to_param + 'old' } + let!(:redirect_route) { model.redirect_routes.create(path: old_path) } + let(:upload_path) { File.basename(upload.path) } + + it 'redirects to a file with the proper extension' do + get :show, params: { group_id: old_path, filename: upload_path, secret: upload.secret } + + expect(response.location).to eq(show_group_uploads_url(group, upload.secret, upload_path)) + expect(response.location).to end_with(upload.path) + expect(response).to have_gitlab_http_status(:redirect) + end + end + def post_authorize(verified: true) request.headers.merge!(workhorse_internal_api_request_header) if verified diff --git a/spec/controllers/health_check_controller_spec.rb b/spec/controllers/health_check_controller_spec.rb index b48b7dc86e0d5a8e3a759f4b0591522b2cb104ce..cbcda5d0dc7b39e6278ba1c56ad1c071dc770ba7 100644 --- a/spec/controllers/health_check_controller_spec.rb +++ b/spec/controllers/health_check_controller_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -describe HealthCheckController do +describe HealthCheckController, :request_store do include StubENV let(:xml_response) { Hash.from_xml(response.body)['hash'] } @@ -18,7 +18,7 @@ describe HealthCheckController do describe 'GET #index' do context 'when services are up but accessed from outside whitelisted ips' do before do - allow(Gitlab::RequestContext).to receive(:client_ip).and_return(not_whitelisted_ip) + allow(Gitlab::RequestContext.instance).to receive(:client_ip).and_return(not_whitelisted_ip) end it 'returns a not found page' do @@ -48,7 +48,7 @@ describe HealthCheckController do context 'when services are up and accessed from whitelisted ips' do before do - allow(Gitlab::RequestContext).to receive(:client_ip).and_return(whitelisted_ip) + allow(Gitlab::RequestContext.instance).to receive(:client_ip).and_return(whitelisted_ip) end it 'supports successful plaintext response' do @@ -95,7 +95,7 @@ describe HealthCheckController do before do allow(HealthCheck::Utils).to receive(:process_checks).with(['standard']).and_return('The server is on fire') allow(HealthCheck::Utils).to receive(:process_checks).with(['email']).and_return('Email is on fire') - allow(Gitlab::RequestContext).to receive(:client_ip).and_return(whitelisted_ip) + allow(Gitlab::RequestContext.instance).to receive(:client_ip).and_return(whitelisted_ip) end it 'supports failure plaintext response' do diff --git a/spec/controllers/metrics_controller_spec.rb b/spec/controllers/metrics_controller_spec.rb index 1d378b9b9dc498309e952e17cdc7eff7864a8c3d..331eafba0d38ed41b153ef7103876f5392aa3f0e 100644 --- a/spec/controllers/metrics_controller_spec.rb +++ b/spec/controllers/metrics_controller_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -describe MetricsController do +describe MetricsController, :request_store do include StubENV let(:metrics_multiproc_dir) { @metrics_multiproc_dir } @@ -53,7 +53,7 @@ describe MetricsController do context 'accessed from whitelisted ip' do before do - allow(Gitlab::RequestContext).to receive(:client_ip).and_return(whitelisted_ip) + allow(Gitlab::RequestContext.instance).to receive(:client_ip).and_return(whitelisted_ip) end it_behaves_like 'endpoint providing metrics' @@ -61,7 +61,7 @@ describe MetricsController do context 'accessed from ip in whitelisted range' do before do - allow(Gitlab::RequestContext).to receive(:client_ip).and_return(ip_in_whitelisted_range) + allow(Gitlab::RequestContext.instance).to receive(:client_ip).and_return(ip_in_whitelisted_range) end it_behaves_like 'endpoint providing metrics' @@ -69,7 +69,7 @@ describe MetricsController do context 'accessed from not whitelisted ip' do before do - allow(Gitlab::RequestContext).to receive(:client_ip).and_return(not_whitelisted_ip) + allow(Gitlab::RequestContext.instance).to receive(:client_ip).and_return(not_whitelisted_ip) end it 'returns the expected error response' do diff --git a/spec/controllers/omniauth_callbacks_controller_spec.rb b/spec/controllers/omniauth_callbacks_controller_spec.rb index 6c5f36804e8b6df668182c31c82a58f5c1bab162..8b92976252c16a009e34dc45051491ba6054ad15 100644 --- a/spec/controllers/omniauth_callbacks_controller_spec.rb +++ b/spec/controllers/omniauth_callbacks_controller_spec.rb @@ -287,6 +287,34 @@ describe OmniauthCallbacksController, type: :controller, do_not_mock_admin_mode: request.env['omniauth.auth'] = Rails.application.env_config['omniauth.auth'] end + context 'sign up' do + before do + user.destroy + end + + it 'denies login if sign up is enabled, but block_auto_created_users is set' do + post :saml, params: { SAMLResponse: mock_saml_response } + + expect(flash[:alert]).to start_with 'Your account has been blocked.' + end + + it 'accepts login if sign up is enabled' do + stub_omniauth_setting(block_auto_created_users: false) + + post :saml, params: { SAMLResponse: mock_saml_response } + + expect(request.env['warden']).to be_authenticated + end + + it 'denies login if sign up is not enabled' do + stub_omniauth_setting(allow_single_sign_on: false, block_auto_created_users: false) + + post :saml, params: { SAMLResponse: mock_saml_response } + + expect(flash[:alert]).to start_with 'Signing in using your saml account without a pre-existing GitLab account is not allowed.' + end + end + context 'with GitLab initiated request' do before do post :saml, params: { SAMLResponse: mock_saml_response } diff --git a/spec/controllers/profiles/preferences_controller_spec.rb b/spec/controllers/profiles/preferences_controller_spec.rb index e0e6d78bdcd4706c3d7df2b15202ae4f723f6f2f..77e7b32af25d36ffae76f4c50b849f7ed9ee6804 100644 --- a/spec/controllers/profiles/preferences_controller_spec.rb +++ b/spec/controllers/profiles/preferences_controller_spec.rb @@ -46,7 +46,8 @@ describe Profiles::PreferencesController do dashboard: 'stars', theme_id: '2', first_day_of_week: '1', - preferred_language: 'jp' + preferred_language: 'jp', + render_whitespace_in_code: 'true' }.with_indifferent_access expect(user).to receive(:assign_attributes).with(ActionController::Parameters.new(prefs).permit!) diff --git a/spec/controllers/projects/branches_controller_spec.rb b/spec/controllers/projects/branches_controller_spec.rb index 4f8ab6a5def96cbf1b32472b8940c3ee35fcd0a7..ac39ac626c79cac9d020f021d6413d0e5267d97c 100644 --- a/spec/controllers/projects/branches_controller_spec.rb +++ b/spec/controllers/projects/branches_controller_spec.rb @@ -35,6 +35,7 @@ describe Projects::BranchesController do context "valid branch name, valid source" do let(:branch) { "merge_branch" } let(:ref) { "master" } + it 'redirects' do expect(subject) .to redirect_to("/#{project.full_path}/tree/merge_branch") @@ -44,6 +45,7 @@ describe Projects::BranchesController do context "invalid branch name, valid ref" do let(:branch) { "<script>alert('merge');</script>" } let(:ref) { "master" } + it 'redirects' do expect(subject) .to redirect_to("/#{project.full_path}/tree/alert('merge');") @@ -53,18 +55,21 @@ describe Projects::BranchesController do context "valid branch name, invalid ref" do let(:branch) { "merge_branch" } let(:ref) { "<script>alert('ref');</script>" } + it { is_expected.to render_template('new') } end context "invalid branch name, invalid ref" do let(:branch) { "<script>alert('merge');</script>" } let(:ref) { "<script>alert('ref');</script>" } + it { is_expected.to render_template('new') } end context "valid branch name with encoded slashes" do let(:branch) { "feature%2Ftest" } let(:ref) { "<script>alert('ref');</script>" } + it { is_expected.to render_template('new') } it { project.repository.branch_exists?('feature/test') } end @@ -586,7 +591,7 @@ describe Projects::BranchesController do params: { namespace_id: project.namespace, project_id: project, - names: ['fix', 'add-pdf-file', 'branch-merged'] + names: %w[fix add-pdf-file branch-merged] } expect(response).to have_gitlab_http_status(200) @@ -634,7 +639,7 @@ describe Projects::BranchesController do params: { namespace_id: project.namespace, project_id: project, - names: ['fix', 'add-pdf-file', 'branch-merged'] + names: %w[fix add-pdf-file branch-merged] } expect(response).to have_gitlab_http_status(200) diff --git a/spec/controllers/projects/ci/lints_controller_spec.rb b/spec/controllers/projects/ci/lints_controller_spec.rb index 3d8f287f999daead1a84cd744f90c490624bb49c..8fb39f734b657def8d286889139bfb76db677c7f 100644 --- a/spec/controllers/projects/ci/lints_controller_spec.rb +++ b/spec/controllers/projects/ci/lints_controller_spec.rb @@ -103,7 +103,7 @@ describe Projects::Ci::LintsController do end it 'assigns errors' do - expect(assigns[:error]).to eq('root config contains unknown keys: rubocop') + expect(assigns[:errors]).to eq(['root config contains unknown keys: rubocop']) end end diff --git a/spec/controllers/projects/clusters_controller_spec.rb b/spec/controllers/projects/clusters_controller_spec.rb index ab8bfc0cabe43d9a6cc74c12874a34778cd8885b..642932e29350b7f1abf6e557d08cff604cd1d895 100644 --- a/spec/controllers/projects/clusters_controller_spec.rb +++ b/spec/controllers/projects/clusters_controller_spec.rb @@ -26,6 +26,7 @@ describe Projects::ClustersController do let(:project) { create(:project) } let!(:enabled_cluster) { create(:cluster, :provided_by_gcp, projects: [project]) } let!(:disabled_cluster) { create(:cluster, :disabled, :provided_by_gcp, :production_environment, projects: [project]) } + it 'lists available clusters' do go diff --git a/spec/controllers/projects/deployments_controller_spec.rb b/spec/controllers/projects/deployments_controller_spec.rb index 66112c95742c3395d9fe730e51291cd45e8ae136..b360319c6b10c24fa90f9a9fc60e961bec4bc114 100644 --- a/spec/controllers/projects/deployments_controller_spec.rb +++ b/spec/controllers/projects/deployments_controller_spec.rb @@ -6,7 +6,7 @@ describe Projects::DeploymentsController do include ApiHelpers let(:user) { create(:user) } - let(:project) { create(:project) } + let(:project) { create(:project, :repository) } let(:environment) { create(:environment, name: 'production', project: project) } before do diff --git a/spec/controllers/projects/environments/prometheus_api_controller_spec.rb b/spec/controllers/projects/environments/prometheus_api_controller_spec.rb index 0940fccb431cde4e446c1e4028e111474542a08b..793c10f0b215d32871204d9c96138abbdf62401c 100644 --- a/spec/controllers/projects/environments/prometheus_api_controller_spec.rb +++ b/spec/controllers/projects/environments/prometheus_api_controller_spec.rb @@ -78,6 +78,40 @@ describe Projects::Environments::PrometheusApiController do end end end + + context 'with variables' do + let(:pod_name) { "pod1" } + + before do + expected_params[:query] = %{up{pod_name="#{pod_name}"}} + expected_params[:variables] = ['pod_name', pod_name] + end + + it 'replaces variables with values' do + get :proxy, params: environment_params.merge( + query: 'up{pod_name="{{pod_name}}"}', variables: ['pod_name', pod_name] + ) + + expect(response).to have_gitlab_http_status(:success) + expect(Prometheus::ProxyService).to have_received(:new) + .with(environment, 'GET', 'query', expected_params) + end + + context 'with invalid variables' do + let(:params_with_invalid_variables) do + environment_params.merge( + query: 'up{pod_name="{{pod_name}}"}', variables: ['a'] + ) + end + + it 'returns 400' do + get :proxy, params: params_with_invalid_variables + + expect(response).to have_gitlab_http_status(:bad_request) + expect(Prometheus::ProxyService).not_to receive(:new) + end + end + end end context 'with nil result' do diff --git a/spec/controllers/projects/environments/sample_metrics_controller_spec.rb b/spec/controllers/projects/environments/sample_metrics_controller_spec.rb index 4faa3ecb5673b4090571320464f32d38e689c952..19b07a2ccc4b3b5fe1ca2ec04078f5a2dc8a29da 100644 --- a/spec/controllers/projects/environments/sample_metrics_controller_spec.rb +++ b/spec/controllers/projects/environments/sample_metrics_controller_spec.rb @@ -9,17 +9,6 @@ describe Projects::Environments::SampleMetricsController do let_it_be(:environment) { create(:environment, project: project) } let_it_be(:user) { create(:user) } - before(:context) do - RSpec::Mocks.with_temporary_scope do - stub_env('USE_SAMPLE_METRICS', 'true') - Rails.application.reload_routes! - end - end - - after(:context) do - Rails.application.reload_routes! - end - before do project.add_reporter(user) sign_in(user) @@ -58,7 +47,9 @@ describe Projects::Environments::SampleMetricsController do id: environment.id.to_s, namespace_id: project.namespace.full_path, project_id: project.name, - identifier: 'sample_metric_query_result' + identifier: 'sample_metric_query_result', + start: '2019-12-02T23:31:45.000Z', + end: '2019-12-03T00:01:45.000Z' }.merge(params) end end diff --git a/spec/controllers/projects/error_tracking/projects_controller_spec.rb b/spec/controllers/projects/error_tracking/projects_controller_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..1737528b59760e5ce8f1d41975d2de3b51e8846b --- /dev/null +++ b/spec/controllers/projects/error_tracking/projects_controller_spec.rb @@ -0,0 +1,122 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Projects::ErrorTracking::ProjectsController do + let_it_be(:project) { create(:project) } + let_it_be(:user) { create(:user) } + + before do + sign_in(user) + project.add_maintainer(user) + end + + describe 'GET #index' do + context 'with insufficient permissions' do + before do + project.add_guest(user) + end + + it 'returns 404' do + get :index, params: list_projects_params + + expect(response).to have_gitlab_http_status(:not_found) + end + end + + context 'with an anonymous user' do + before do + sign_out(user) + end + + it 'redirects to sign-in page' do + get :index, params: list_projects_params + + expect(response).to have_gitlab_http_status(:redirect) + end + end + + context 'with authorized user' do + let(:list_projects_service) { spy(:list_projects_service) } + let(:sentry_project) { build(:error_tracking_project) } + + let(:query_params) do + list_projects_params.slice(:api_host, :token) + end + + before do + allow(ErrorTracking::ListProjectsService) + .to receive(:new).with(project, user, query_params) + .and_return(list_projects_service) + end + + context 'service result is successful' do + before do + expect(list_projects_service).to receive(:execute) + .and_return(status: :success, projects: [sentry_project]) + end + + it 'returns a list of projects' do + get :index, params: list_projects_params + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to match_response_schema('error_tracking/list_projects') + expect(json_response['projects']).to eq([sentry_project].as_json) + end + end + + context 'service result is erroneous' do + let(:error_message) { 'error message' } + + context 'without http_status' do + before do + expect(list_projects_service).to receive(:execute) + .and_return(status: :error, message: error_message) + end + + it 'returns 400 with message' do + get :index, params: list_projects_params + + expect(response).to have_gitlab_http_status(:bad_request) + expect(json_response['message']).to eq(error_message) + end + end + + context 'with explicit http_status' do + let(:http_status) { :no_content } + + before do + expect(list_projects_service).to receive(:execute).and_return( + status: :error, + message: error_message, + http_status: http_status + ) + end + + it 'returns http_status with message' do + get :index, params: list_projects_params + + expect(response).to have_gitlab_http_status(http_status) + expect(json_response['message']).to eq(error_message) + end + end + end + end + + private + + def list_projects_params(opts = {}) + project_params( + format: :json, + api_host: 'gitlab.com', + token: 'token' + ) + end + end + + private + + def project_params(opts = {}) + opts.reverse_merge(namespace_id: project.namespace, project_id: project) + end +end diff --git a/spec/controllers/projects/error_tracking/stack_traces_controller_spec.rb b/spec/controllers/projects/error_tracking/stack_traces_controller_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..75e1c817baa71e37803c3674ee451faa36d68ee3 --- /dev/null +++ b/spec/controllers/projects/error_tracking/stack_traces_controller_spec.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Projects::ErrorTracking::StackTracesController do + let_it_be(:project) { create(:project) } + let_it_be(:user) { create(:user) } + + before do + sign_in(user) + project.add_maintainer(user) + end + + describe 'GET #index' do + let(:issue_id) { 1234 } + let(:issue_stack_trace_service) { spy(:issue_stack_trace_service) } + + subject(:get_stack_trace) do + get :index, params: { namespace_id: project.namespace, project_id: project, issue_id: issue_id, format: :json } + end + + before do + expect(ErrorTracking::IssueLatestEventService) + .to receive(:new).with(project, user, issue_id: issue_id.to_s) + .and_return(issue_stack_trace_service) + expect(issue_stack_trace_service).to receive(:execute).and_return(service_response) + + get_stack_trace + end + + context 'awaiting data' do + let(:service_response) { { status: :error, http_status: :no_content }} + + it 'responds with no data' do + expect(response).to have_gitlab_http_status(:no_content) + end + + it_behaves_like 'sets the polling header' + end + + context 'service result is successful' do + let(:service_response) { { status: :success, latest_event: error_event } } + let(:error_event) { build(:error_tracking_error_event) } + + it 'responds with success' do + expect(response).to have_gitlab_http_status(:ok) + end + + it 'responds with error' do + expect(response).to match_response_schema('error_tracking/issue_stack_trace') + end + + it 'highlights stack trace source code' do + expect(json_response['error']).to eq( + Gitlab::ErrorTracking::StackTraceHighlightDecorator.decorate(error_event).as_json + ) + end + + it_behaves_like 'sets the polling header' + end + + context 'service result is erroneous' do + let(:error_message) { 'error message' } + + context 'without http_status' do + let(:service_response) { { status: :error, message: error_message } } + + it 'responds with bad request' do + expect(response).to have_gitlab_http_status(:bad_request) + end + + it 'responds with error message' do + expect(json_response['message']).to eq(error_message) + end + end + + context 'with explicit http_status' do + let(:http_status) { :no_content } + let(:service_response) { { status: :error, message: error_message, http_status: http_status } } + + it 'responds with custom http status' do + expect(response).to have_gitlab_http_status(http_status) + end + + it 'responds with error message' do + expect(json_response['message']).to eq(error_message) + end + end + end + end +end diff --git a/spec/controllers/projects/error_tracking_controller_spec.rb b/spec/controllers/projects/error_tracking_controller_spec.rb index e5585d7b52d7dec648728c6f9bc02defb369bd15..588c4b0552884a752a1db28cbab80263c74b184c 100644 --- a/spec/controllers/projects/error_tracking_controller_spec.rb +++ b/spec/controllers/projects/error_tracking_controller_spec.rb @@ -91,13 +91,13 @@ describe Projects::ErrorTrackingController do .and_return(status: :success, issues: [error], pagination: {}) expect(list_issues_service).to receive(:external_url) .and_return(external_url) + + get :index, params: params end let(:error) { build(:error_tracking_error) } it 'returns a list of errors' do - get :index, params: params - expect(response).to have_gitlab_http_status(:ok) expect(response).to match_response_schema('error_tracking/index') expect(json_response).to eq( @@ -106,6 +106,8 @@ describe Projects::ErrorTrackingController do 'external_url' => external_url ) end + + it_behaves_like 'sets the polling header' end end @@ -179,113 +181,6 @@ describe Projects::ErrorTrackingController do end end - describe 'POST #list_projects' do - context 'with insufficient permissions' do - before do - project.add_guest(user) - end - - it 'returns 404' do - post :list_projects, params: list_projects_params - - expect(response).to have_gitlab_http_status(:not_found) - end - end - - context 'with an anonymous user' do - before do - sign_out(user) - end - - it 'redirects to sign-in page' do - post :list_projects, params: list_projects_params - - expect(response).to have_gitlab_http_status(:redirect) - end - end - - context 'with authorized user' do - let(:list_projects_service) { spy(:list_projects_service) } - let(:sentry_project) { build(:error_tracking_project) } - - let(:permitted_params) do - ActionController::Parameters.new( - list_projects_params[:error_tracking_setting] - ).permit! - end - - before do - allow(ErrorTracking::ListProjectsService) - .to receive(:new).with(project, user, permitted_params) - .and_return(list_projects_service) - end - - context 'service result is successful' do - before do - expect(list_projects_service).to receive(:execute) - .and_return(status: :success, projects: [sentry_project]) - end - - it 'returns a list of projects' do - post :list_projects, params: list_projects_params - - expect(response).to have_gitlab_http_status(:ok) - expect(response).to match_response_schema('error_tracking/list_projects') - expect(json_response['projects']).to eq([sentry_project].as_json) - end - end - - context 'service result is erroneous' do - let(:error_message) { 'error message' } - - context 'without http_status' do - before do - expect(list_projects_service).to receive(:execute) - .and_return(status: :error, message: error_message) - end - - it 'returns 400 with message' do - get :list_projects, params: list_projects_params - - expect(response).to have_gitlab_http_status(:bad_request) - expect(json_response['message']).to eq(error_message) - end - end - - context 'with explicit http_status' do - let(:http_status) { :no_content } - - before do - expect(list_projects_service).to receive(:execute).and_return( - status: :error, - message: error_message, - http_status: http_status - ) - end - - it 'returns http_status with message' do - get :list_projects, params: list_projects_params - - expect(response).to have_gitlab_http_status(http_status) - expect(json_response['message']).to eq(error_message) - end - end - end - end - - private - - def list_projects_params(opts = {}) - project_params( - format: :json, - error_tracking_setting: { - api_host: 'gitlab.com', - token: 'token' - } - ) - end - end - describe 'GET #issue_details' do let_it_be(:issue_id) { 1234 } @@ -308,30 +203,40 @@ describe Projects::ErrorTrackingController do before do expect(issue_details_service).to receive(:execute) .and_return(status: :error, http_status: :no_content) + get :details, params: issue_params(issue_id: issue_id, format: :json) end it 'returns no data' do - get :details, params: issue_params(issue_id: issue_id, format: :json) - expect(response).to have_gitlab_http_status(:no_content) end + + it_behaves_like 'sets the polling header' end context 'service result is successful' do before do expect(issue_details_service).to receive(:execute) .and_return(status: :success, issue: error) + + get :details, params: issue_params(issue_id: issue_id, format: :json) end let(:error) { build(:detailed_error_tracking_error) } it 'returns an error' do - get :details, params: issue_params(issue_id: issue_id, format: :json) + expected_error = error.as_json.except('first_release_version').merge( + { + 'gitlab_commit' => nil, + 'gitlab_commit_path' => nil + } + ) expect(response).to have_gitlab_http_status(:ok) expect(response).to match_response_schema('error_tracking/issue_detailed') - expect(json_response['error']).to eq(error.as_json) + expect(json_response['error']).to eq(expected_error) end + + it_behaves_like 'sets the polling header' end context 'service result is erroneous' do @@ -373,97 +278,53 @@ describe Projects::ErrorTrackingController do end end - describe 'GET #stack_trace' do - let_it_be(:issue_id) { 1234 } - - let(:issue_stack_trace_service) { spy(:issue_stack_trace_service) } - + describe 'PUT #update' do + let(:issue_id) { 1234 } + let(:issue_update_service) { spy(:issue_update_service) } let(:permitted_params) do ActionController::Parameters.new( - { issue_id: issue_id.to_s } + { issue_id: issue_id.to_s, status: 'resolved' } ).permit! end - subject(:get_stack_trace) do - get :stack_trace, params: issue_params(issue_id: issue_id, format: :json) + subject(:update_issue) do + put :update, params: issue_params(issue_id: issue_id, status: 'resolved', format: :json) end before do - expect(ErrorTracking::IssueLatestEventService) + expect(ErrorTracking::IssueUpdateService) .to receive(:new).with(project, user, permitted_params) - .and_return(issue_stack_trace_service) + .and_return(issue_update_service) end describe 'format json' do - context 'awaiting data' do - before do - expect(issue_stack_trace_service).to receive(:execute) - .and_return(status: :error, http_status: :no_content) - end - - it 'returns no data' do - get_stack_trace - - expect(response).to have_gitlab_http_status(:no_content) - end - end - - context 'service result is successful' do + context 'update result is successful' do before do - expect(issue_stack_trace_service).to receive(:execute) - .and_return(status: :success, latest_event: error_event) + expect(issue_update_service).to receive(:execute) + .and_return(status: :success, updated: true) - get_stack_trace + update_issue end - let(:error_event) { build(:error_tracking_error_event) } - - it 'returns an error' do + it 'returns a success' do expect(response).to have_gitlab_http_status(:ok) - expect(response).to match_response_schema('error_tracking/issue_stack_trace') - end - - it 'highlights stack trace source code' do - expect(json_response['error']).to eq( - Gitlab::ErrorTracking::StackTraceHighlightDecorator.decorate(error_event).as_json - ) + expect(response).to match_response_schema('error_tracking/update_issue') end end - context 'service result is erroneous' do + context 'update result is erroneous' do let(:error_message) { 'error message' } - context 'without http_status' do - before do - expect(issue_stack_trace_service).to receive(:execute) - .and_return(status: :error, message: error_message) - end - - it 'returns 400 with message' do - get_stack_trace + before do + expect(issue_update_service).to receive(:execute) + .and_return(status: :error, message: error_message) - expect(response).to have_gitlab_http_status(:bad_request) - expect(json_response['message']).to eq(error_message) - end + update_issue end - context 'with explicit http_status' do - let(:http_status) { :no_content } - - before do - expect(issue_stack_trace_service).to receive(:execute).and_return( - status: :error, - message: error_message, - http_status: http_status - ) - end - - it 'returns http_status with message' do - get_stack_trace - - expect(response).to have_gitlab_http_status(http_status) - expect(json_response['message']).to eq(error_message) - end + it 'returns 400 with message' do + expect(response).to have_gitlab_http_status(:bad_request) + expect(json_response['message']).to eq(error_message) end end end diff --git a/spec/controllers/projects/find_file_controller_spec.rb b/spec/controllers/projects/find_file_controller_spec.rb index a493985f8a06a80f541b90e734a7fcc1d58a5d8d..4d8933f3aaf6e5cee5c0b309e125dbde2aeee928 100644 --- a/spec/controllers/projects/find_file_controller_spec.rb +++ b/spec/controllers/projects/find_file_controller_spec.rb @@ -28,11 +28,13 @@ describe Projects::FindFileController do context "valid branch" do let(:id) { 'master' } + it { is_expected.to respond_with(:success) } end context "invalid branch" do let(:id) { 'invalid-branch' } + it { is_expected.to respond_with(:not_found) } end end @@ -50,6 +52,7 @@ describe Projects::FindFileController do context "valid branch" do let(:id) { 'master' } + it 'returns an array of file path list' do go diff --git a/spec/controllers/projects/forks_controller_spec.rb b/spec/controllers/projects/forks_controller_spec.rb index 80b5eb9a7ee7620ebfa52fcc7b41af37d182b000..e351fb2b1f68d7e4c325fd4a357f35ded7c270a7 100644 --- a/spec/controllers/projects/forks_controller_spec.rb +++ b/spec/controllers/projects/forks_controller_spec.rb @@ -12,6 +12,21 @@ describe Projects::ForksController do group.add_owner(user) end + shared_examples 'forking disabled' do + let(:project) { create(:project, :private, :repository, :forking_disabled) } + + before do + project.add_developer(user) + sign_in(user) + end + + it 'returns with 404' do + subject + + expect(response).to have_gitlab_http_status(404) + end + end + describe 'GET index' do def get_forks(search: nil) get :index, @@ -138,19 +153,19 @@ describe Projects::ForksController do end describe 'GET new' do - def get_new + subject do get :new, - params: { - namespace_id: project.namespace, - project_id: project - } + params: { + namespace_id: project.namespace, + project_id: project + } end context 'when user is signed in' do it 'responds with status 200' do sign_in(user) - get_new + subject expect(response).to have_gitlab_http_status(200) end @@ -160,21 +175,26 @@ describe Projects::ForksController do it 'redirects to the sign-in page' do sign_out(user) - get_new + subject expect(response).to redirect_to(new_user_session_path) end end + + it_behaves_like 'forking disabled' end describe 'POST create' do - def post_create(params = {}) - post :create, - params: { - namespace_id: project.namespace, - project_id: project, - namespace_key: user.namespace.id - }.merge(params) + let(:params) do + { + namespace_id: project.namespace, + project_id: project, + namespace_key: user.namespace.id + } + end + + subject do + post :create, params: params end context 'when user is signed in' do @@ -183,18 +203,34 @@ describe Projects::ForksController do end it 'responds with status 302' do - post_create + subject expect(response).to have_gitlab_http_status(302) expect(response).to redirect_to(namespace_project_import_path(user.namespace, project)) end - it 'passes continue params to the redirect' do - continue_params = { to: '/-/ide/project/path', notice: 'message' } - post_create continue: continue_params + context 'continue params' do + let(:params) do + { + namespace_id: project.namespace, + project_id: project, + namespace_key: user.namespace.id, + continue: continue_params + } + end + let(:continue_params) do + { + to: '/-/ide/project/path', + notice: 'message' + } + end - expect(response).to have_gitlab_http_status(302) - expect(response).to redirect_to(namespace_project_import_path(user.namespace, project, continue: continue_params)) + it 'passes continue params to the redirect' do + subject + + expect(response).to have_gitlab_http_status(302) + expect(response).to redirect_to(namespace_project_import_path(user.namespace, project, continue: continue_params)) + end end end @@ -202,10 +238,12 @@ describe Projects::ForksController do it 'redirects to the sign-in page' do sign_out(user) - post_create + subject expect(response).to redirect_to(new_user_session_path) end end + + it_behaves_like 'forking disabled' end end diff --git a/spec/controllers/projects/git_http_controller_spec.rb b/spec/controllers/projects/git_http_controller_spec.rb index b756dd5662d18433faff65189cee028835f7576a..4df53121aaa2675e08dfbd25c6910b6a8e2b4c20 100644 --- a/spec/controllers/projects/git_http_controller_spec.rb +++ b/spec/controllers/projects/git_http_controller_spec.rb @@ -3,10 +3,19 @@ require 'spec_helper' describe Projects::GitHttpController do + include GitHttpHelpers + + let_it_be(:project) { create(:project, :public, :repository) } + let(:project_params) do + { + namespace_id: project.namespace.to_param, + project_id: project.path + '.git' + } + end + let(:params) { project_params } + describe 'HEAD #info_refs' do it 'returns 403' do - project = create(:project, :public, :repository) - head :info_refs, params: { namespace_id: project.namespace.to_param, project_id: project.path + '.git' } expect(response.status).to eq(403) @@ -14,18 +23,39 @@ describe Projects::GitHttpController do end describe 'GET #info_refs' do + let(:params) { project_params.merge(service: 'git-upload-pack') } + it 'returns 401 for unauthenticated requests to public repositories when http protocol is disabled' do stub_application_setting(enabled_git_access_protocol: 'ssh') - project = create(:project, :public, :repository) - get :info_refs, params: { service: 'git-upload-pack', namespace_id: project.namespace.to_param, project_id: project.path + '.git' } + get :info_refs, params: params expect(response.status).to eq(401) end - context 'with exceptions' do - let(:project) { create(:project, :public, :repository) } + context 'with authorized user' do + let(:user) { project.owner } + + before do + request.headers.merge! auth_env(user.username, user.password, nil) + end + + it 'returns 200' do + get :info_refs, params: params + + expect(response.status).to eq(200) + end + + it 'updates the user activity' do + expect_next_instance_of(Users::ActivityService) do |activity_service| + expect(activity_service).to receive(:execute) + end + + get :info_refs, params: params + end + end + context 'with exceptions' do before do allow(controller).to receive(:verify_workhorse_api!).and_return(true) end @@ -33,7 +63,7 @@ describe Projects::GitHttpController do it 'returns 503 with GRPC Unavailable' do allow(controller).to receive(:access_check).and_raise(GRPC::Unavailable) - get :info_refs, params: { service: 'git-upload-pack', namespace_id: project.namespace.to_param, project_id: project.path + '.git' } + get :info_refs, params: params expect(response.status).to eq(503) end @@ -41,11 +71,37 @@ describe Projects::GitHttpController do it 'returns 503 with timeout error' do allow(controller).to receive(:access_check).and_raise(Gitlab::GitAccess::TimeoutError) - get :info_refs, params: { service: 'git-upload-pack', namespace_id: project.namespace.to_param, project_id: project.path + '.git' } + get :info_refs, params: params expect(response.status).to eq(503) expect(response.body).to eq 'Gitlab::GitAccess::TimeoutError' end end end + + describe 'POST #git_upload_pack' do + before do + allow(controller).to receive(:authenticate_user).and_return(true) + allow(controller).to receive(:verify_workhorse_api!).and_return(true) + allow(controller).to receive(:access_check).and_return(nil) + end + + after do + post :git_upload_pack, params: params + end + + context 'on a read-only instance' do + before do + allow(Gitlab::Database).to receive(:read_only?).and_return(true) + end + + it 'does not update project statistics' do + expect(ProjectDailyStatisticsWorker).not_to receive(:perform_async) + end + end + + it 'updates project statistics' do + expect(ProjectDailyStatisticsWorker).to receive(:perform_async) + end + end end diff --git a/spec/controllers/projects/issues_controller_spec.rb b/spec/controllers/projects/issues_controller_spec.rb index f64e928098de2d6fbcdb1201ab7aa7f0c8d3fce8..945a56365c856debfff490ce8d37e935b78d2626 100644 --- a/spec/controllers/projects/issues_controller_spec.rb +++ b/spec/controllers/projects/issues_controller_spec.rb @@ -1357,6 +1357,7 @@ describe Projects::IssuesController do describe 'GET #discussions' do let!(:discussion) { create(:discussion_note_on_issue, noteable: issue, project: issue.project) } + context 'when authenticated' do before do project.add_developer(user) diff --git a/spec/controllers/projects/jobs_controller_spec.rb b/spec/controllers/projects/jobs_controller_spec.rb index edef24f6595056512fc88d5705158215d45d335d..53c40683a5bf4be0daae5d1bbc2ae68179494892 100644 --- a/spec/controllers/projects/jobs_controller_spec.rb +++ b/spec/controllers/projects/jobs_controller_spec.rb @@ -556,6 +556,12 @@ describe Projects::JobsController, :clean_gitlab_redis_shared_state do expect(json_response['status']).to eq job.status expect(json_response['lines']).to eq [{ 'content' => [{ 'text' => 'BUILD TRACE' }], 'offset' => 0 }] end + + it 'sets being-watched flag for the job' do + expect(response).to have_gitlab_http_status(:ok) + + expect(job.trace.being_watched?).to be(true) + end end context 'when job has no traces' do diff --git a/spec/controllers/projects/merge_requests_controller_spec.rb b/spec/controllers/projects/merge_requests_controller_spec.rb index 3b7d8adb8e5468c322bb1668b6a4fe397283a028..d5b1bfe0ac4263210f38ee7f19f4ee6a1f755edd 100644 --- a/spec/controllers/projects/merge_requests_controller_spec.rb +++ b/spec/controllers/projects/merge_requests_controller_spec.rb @@ -1289,19 +1289,6 @@ describe Projects::MergeRequestsController do get_ci_environments_status(environment_target: 'merge_commit') end - context 'when the deployment_merge_requests_widget feature flag is disabled' do - it 'uses the deployments retrieved using CI builds' do - stub_feature_flags(deployment_merge_requests_widget: false) - - expect(EnvironmentStatus) - .to receive(:after_merge_request) - .with(merge_request, user) - .and_call_original - - get_ci_environments_status(environment_target: 'merge_commit') - end - end - def get_ci_environments_status(extra_params = {}) params = { namespace_id: merge_request.project.namespace.to_param, @@ -1389,7 +1376,7 @@ describe Projects::MergeRequestsController do end def expect_rebase_worker_for(user) - expect(RebaseWorker).to receive(:perform_async).with(merge_request.id, user.id) + expect(RebaseWorker).to receive(:perform_async).with(merge_request.id, user.id, false) end context 'successfully' do @@ -1425,7 +1412,7 @@ describe Projects::MergeRequestsController do post_rebase expect(response.status).to eq(409) - expect(json_response['merge_error']).to eq(MergeRequest::REBASE_LOCK_MESSAGE) + expect(json_response['merge_error']).to eq('Failed to enqueue the rebase operation, possibly due to a long-lived transaction. Try again later.') end end diff --git a/spec/controllers/projects/pages_controller_spec.rb b/spec/controllers/projects/pages_controller_spec.rb index f80bbf0d78fc7a6687920aa651cc63f4ea1c20c9..c07619465bf9496bbbc1b47e5b56c95b9b557d62 100644 --- a/spec/controllers/projects/pages_controller_spec.rb +++ b/spec/controllers/projects/pages_controller_spec.rb @@ -115,5 +115,16 @@ describe Projects::PagesController do patch :update, params: request_params end + + context 'when update_service returns an error message' do + let(:update_service) { double(execute: { status: :error, message: 'some error happened' }) } + + it 'adds an error message' do + patch :update, params: request_params + + expect(response).to redirect_to(project_pages_path(project)) + expect(flash[:alert]).to eq('some error happened') + end + end end end diff --git a/spec/controllers/projects/performance_monitoring/dashboards_controller_spec.rb b/spec/controllers/projects/performance_monitoring/dashboards_controller_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..1c29b68dc24ada86f7f8654172e153dd5a787d5b --- /dev/null +++ b/spec/controllers/projects/performance_monitoring/dashboards_controller_spec.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Projects::PerformanceMonitoring::DashboardsController do + let_it_be(:user) { create(:user) } + let_it_be(:namespace) { create(:namespace) } + let!(:project) { create(:project, :repository, name: 'dashboard-project', namespace: namespace) } + let(:repository) { project.repository } + let(:branch) { double(name: branch_name) } + let(:commit_message) { 'test' } + let(:branch_name) { "#{Time.current.to_i}_dashboard_new_branch" } + let(:dashboard) { 'config/prometheus/common_metrics.yml' } + let(:file_name) { 'custom_dashboard.yml' } + let(:params) do + { + namespace_id: namespace, + project_id: project, + dashboard: dashboard, + file_name: file_name, + commit_message: commit_message, + branch: branch_name, + format: :json + } + end + + describe 'POST #create' do + context 'authenticated user' do + before do + sign_in(user) + end + + context 'project with repository feature' do + context 'with rights to push to the repository' do + before do + project.add_maintainer(user) + end + + context 'valid parameters' do + it 'delegates cloning to ::Metrics::Dashboard::CloneDashboardService' do + allow(controller).to receive(:repository).and_return(repository) + allow(repository).to receive(:find_branch).and_return(branch) + dashboard_attrs = { + dashboard: dashboard, + file_name: file_name, + commit_message: commit_message, + branch: branch_name + } + + service_instance = instance_double(::Metrics::Dashboard::CloneDashboardService) + expect(::Metrics::Dashboard::CloneDashboardService).to receive(:new).with(project, user, dashboard_attrs).and_return(service_instance) + expect(service_instance).to receive(:execute).and_return(status: :success, http_status: :created, dashboard: { path: 'dashboard/path' }) + + post :create, params: params + end + + context 'request format json' do + it 'returns services response' do + allow(::Metrics::Dashboard::CloneDashboardService).to receive(:new).and_return(double(execute: { status: :success, dashboard: { path: ".gitlab/dashboards/#{file_name}" }, http_status: :created })) + allow(controller).to receive(:repository).and_return(repository) + allow(repository).to receive(:find_branch).and_return(branch) + + post :create, params: params + + expect(response).to have_gitlab_http_status :created + expect(response).to set_flash[:notice].to eq("Your dashboard has been copied. You can <a href=\"/-/ide/project/#{namespace.path}/#{project.name}/edit/#{branch_name}/-/.gitlab/dashboards/#{file_name}\">edit it here</a>.") + expect(json_response).to eq('status' => 'success', 'dashboard' => { 'path' => ".gitlab/dashboards/#{file_name}" }) + end + + context 'Metrics::Dashboard::CloneDashboardService failure' do + it 'returns json with failure message', :aggregate_failures do + allow(::Metrics::Dashboard::CloneDashboardService).to receive(:new).and_return(double(execute: { status: :error, message: 'something went wrong', http_status: :bad_request })) + + post :create, params: params + + expect(response).to have_gitlab_http_status :bad_request + expect(json_response).to eq('error' => 'something went wrong') + end + end + + %w(commit_message file_name dashboard).each do |param| + context "param #{param} is missing" do + let(param.to_s) { nil } + + it 'responds with bad request status and error message', :aggregate_failures do + post :create, params: params + + expect(response).to have_gitlab_http_status :bad_request + expect(json_response).to eq('error' => "Request parameter #{param} is missing.") + end + end + end + + context "param branch_name is missing" do + let(:branch_name) { nil } + + it 'responds with bad request status and error message', :aggregate_failures do + post :create, params: params + + expect(response).to have_gitlab_http_status :bad_request + expect(json_response).to eq('error' => "Request parameter branch is missing.") + end + end + end + end + end + + context 'without rights to push to repository' do + before do + project.add_guest(user) + end + + it 'responds with :forbidden status code' do + post :create, params: params + + expect(response).to have_gitlab_http_status :forbidden + end + end + end + + context 'project without repository feature' do + let!(:project) { create(:project, name: 'dashboard-project', namespace: namespace) } + + it 'responds with :not_found status code' do + post :create, params: params + + expect(response).to have_gitlab_http_status :not_found + end + end + end + end +end diff --git a/spec/controllers/projects/pipelines_controller_spec.rb b/spec/controllers/projects/pipelines_controller_spec.rb index 902a84a843b7fb4c84ea161fb63ca890f0813bfd..4cc5b3cba7c7dcec1b5a4be7184f8ae47f638094 100644 --- a/spec/controllers/projects/pipelines_controller_spec.rb +++ b/spec/controllers/projects/pipelines_controller_spec.rb @@ -740,4 +740,51 @@ describe Projects::PipelinesController do expect(response).to have_gitlab_http_status(404) end end + + describe 'DELETE #destroy' do + let!(:project) { create(:project, :private, :repository) } + let!(:pipeline) { create(:ci_pipeline, :failed, project: project) } + let!(:build) { create(:ci_build, :failed, pipeline: pipeline) } + + context 'when user has ability to delete pipeline' do + before do + sign_in(project.owner) + end + + it 'deletes pipeline and redirects' do + delete_pipeline + + expect(response).to have_gitlab_http_status(303) + + expect(Ci::Build.exists?(build.id)).to be_falsy + expect(Ci::Pipeline.exists?(pipeline.id)).to be_falsy + end + + context 'and builds are disabled' do + let(:feature) { ProjectFeature::DISABLED } + + it 'fails to delete pipeline' do + delete_pipeline + + expect(response).to have_gitlab_http_status(404) + end + end + end + + context 'when user has no privileges' do + it 'fails to delete pipeline' do + delete_pipeline + + expect(response).to have_gitlab_http_status(403) + end + end + + def delete_pipeline + delete :destroy, params: { + namespace_id: project.namespace, + project_id: project, + id: pipeline.id + } + end + end end diff --git a/spec/controllers/projects/prometheus/metrics_controller_spec.rb b/spec/controllers/projects/prometheus/metrics_controller_spec.rb index afdb8bbc983a2b7237ef848eae5363b04e5bd2bc..157948de29d01b58dce470f1337e8fb00b0cf42f 100644 --- a/spec/controllers/projects/prometheus/metrics_controller_spec.rb +++ b/spec/controllers/projects/prometheus/metrics_controller_spec.rb @@ -85,7 +85,7 @@ describe Projects::Prometheus::MetricsController do end it 'calls prometheus adapter service' do - expect_next_instance_of(::Prometheus::AdapterService) do |instance| + expect_next_instance_of(::Gitlab::Prometheus::Adapter) do |instance| expect(instance).to receive(:prometheus_adapter) end diff --git a/spec/controllers/projects/raw_controller_spec.rb b/spec/controllers/projects/raw_controller_spec.rb index bdf1c1a84d3843a3158e589fe15b1cf2af34e55a..a570db12d946d7b72e0a6e1f429d9b20e2470331 100644 --- a/spec/controllers/projects/raw_controller_spec.rb +++ b/spec/controllers/projects/raw_controller_spec.rb @@ -56,10 +56,13 @@ describe Projects::RawController do stub_application_setting(raw_blob_request_limit: 5) end - it 'prevents from accessing the raw file' do - execute_raw_requests(requests: 6, project: project, file_path: file_path) + it 'prevents from accessing the raw file', :request_store do + execute_raw_requests(requests: 5, project: project, file_path: file_path) + + expect { execute_raw_requests(requests: 1, project: project, file_path: file_path) } + .to change { Gitlab::GitalyClient.get_request_count }.by(0) - expect(flash[:alert]).to eq(_('You cannot access the raw file. Please wait a minute.')) + expect(response.body).to eq(_('You cannot access the raw file. Please wait a minute.')) expect(response).to have_gitlab_http_status(429) end @@ -109,7 +112,7 @@ describe Projects::RawController do execute_raw_requests(requests: 3, project: project, file_path: modified_path) - expect(flash[:alert]).to eq(_('You cannot access the raw file. Please wait a minute.')) + expect(response.body).to eq(_('You cannot access the raw file. Please wait a minute.')) expect(response).to have_gitlab_http_status(429) end end @@ -137,7 +140,7 @@ describe Projects::RawController do # Accessing downcase version of readme execute_raw_requests(requests: 6, project: project, file_path: file_path) - expect(flash[:alert]).to eq(_('You cannot access the raw file. Please wait a minute.')) + expect(response.body).to eq(_('You cannot access the raw file. Please wait a minute.')) expect(response).to have_gitlab_http_status(429) # Accessing upcase version of readme diff --git a/spec/controllers/projects/serverless/functions_controller_spec.rb b/spec/controllers/projects/serverless/functions_controller_spec.rb index 33d66f4ac5ac4c48ec5f99a4d3a79785226c154e..7e98ded88a9de967675fd1eb1d0353dfe41c439e 100644 --- a/spec/controllers/projects/serverless/functions_controller_spec.rb +++ b/spec/controllers/projects/serverless/functions_controller_spec.rb @@ -7,9 +7,9 @@ describe Projects::Serverless::FunctionsController do include ReactiveCachingHelpers let(:user) { create(:user) } - let(:cluster) { create(:cluster, :project, :provided_by_gcp) } + let(:project) { create(:project, :repository) } + let(:cluster) { create(:cluster, :project, :provided_by_gcp, projects: [project]) } let(:service) { cluster.platform_kubernetes } - let(:project) { cluster.project } let(:environment) { create(:environment, project: project) } let!(:deployment) { create(:deployment, :success, environment: environment, cluster: cluster) } let(:knative_services_finder) { environment.knative_services_finder } diff --git a/spec/controllers/projects/snippets_controller_spec.rb b/spec/controllers/projects/snippets_controller_spec.rb index d372a94db56c092ad15447fa1e3afdca4327915b..ee145a62b5705794f7629545c9659badedb2dc3d 100644 --- a/spec/controllers/projects/snippets_controller_spec.rb +++ b/spec/controllers/projects/snippets_controller_spec.rb @@ -445,4 +445,64 @@ describe Projects::SnippetsController do end end end + + describe 'DELETE #destroy' do + let!(:snippet) { create(:project_snippet, :private, project: project, author: user) } + + let(:params) do + { + namespace_id: project.namespace.to_param, + project_id: project, + id: snippet.to_param + } + end + + context 'when current user has ability to destroy the snippet' do + before do + sign_in(user) + end + + it 'removes the snippet' do + delete :destroy, params: params + + expect { snippet.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + + context 'when snippet is succesfuly destroyed' do + it 'redirects to the project snippets page' do + delete :destroy, params: params + + expect(response).to redirect_to(project_snippets_path(project)) + end + end + + context 'when snippet is not destroyed' do + before do + allow(snippet).to receive(:destroy).and_return(false) + controller.instance_variable_set(:@snippet, snippet) + end + + it 'renders the snippet page with errors' do + delete :destroy, params: params + + expect(flash[:alert]).to eq('Failed to remove snippet.') + expect(response).to redirect_to(project_snippet_path(project, snippet)) + end + end + end + + context 'when current_user does not have ability to destroy the snippet' do + let(:another_user) { create(:user) } + + before do + sign_in(another_user) + end + + it 'responds with status 404' do + delete :destroy, params: params + + 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 7e5237facf639172f0fe4e6d81ccb53305a1063c..15ef1c65c53c7f022ec43ca78969cc4dfc88fc7a 100644 --- a/spec/controllers/projects/tags_controller_spec.rb +++ b/spec/controllers/projects/tags_controller_spec.rb @@ -29,11 +29,13 @@ describe Projects::TagsController do context "valid tag" do let(:id) { 'v1.0.0' } + it { is_expected.to respond_with(:success) } end context "invalid tag" do let(:id) { 'latest' } + it { is_expected.to respond_with(:not_found) } end end diff --git a/spec/controllers/projects/tree_controller_spec.rb b/spec/controllers/projects/tree_controller_spec.rb index c0c11db5dd632938a4799f6c1485c3df6b49a4de..7c9abdf700bfae17c33d2e22988eafd347aff779 100644 --- a/spec/controllers/projects/tree_controller_spec.rb +++ b/spec/controllers/projects/tree_controller_spec.rb @@ -89,6 +89,34 @@ describe Projects::TreeController do end end + describe "GET show" do + context 'lfs_blob_ids instance variable' do + let(:id) { 'master' } + + context 'with vue tree view enabled' do + before do + get(:show, params: { namespace_id: project.namespace.to_param, project_id: project, id: id }) + end + + it 'is not set' do + expect(assigns[:lfs_blob_ids]).to be_nil + end + end + + context 'with vue tree view disabled' do + before do + stub_feature_flags(vue_file_list: false) + + get(:show, params: { namespace_id: project.namespace.to_param, project_id: project, id: id }) + end + + it 'is set' do + expect(assigns[:lfs_blob_ids]).not_to be_nil + end + end + end + end + describe 'GET show with whitespace in ref' do render_views diff --git a/spec/controllers/projects/uploads_controller_spec.rb b/spec/controllers/projects/uploads_controller_spec.rb index cd6a9886f72a41eae2df17fe08ec7bfb6f8c56ad..a70669e86a6420d85e10077a0272b8e8f429678b 100644 --- a/spec/controllers/projects/uploads_controller_spec.rb +++ b/spec/controllers/projects/uploads_controller_spec.rb @@ -25,6 +25,21 @@ describe Projects::UploadsController do end end + context 'with a moved project' do + let!(:upload) { create(:upload, :issuable_upload, :with_file, model: model) } + let(:project) { model } + let(:upload_path) { File.basename(upload.path) } + let!(:redirect_route) { project.redirect_routes.create(path: project.full_path + 'old') } + + it 'redirects to a file with the proper extension' do + get :show, params: { namespace_id: project.namespace, project_id: project.to_param + 'old', filename: File.basename(upload.path), secret: upload.secret } + + expect(response.location).to eq(show_project_uploads_url(project, upload.secret, upload_path)) + expect(response.location).to end_with(upload.path) + expect(response).to have_gitlab_http_status(:redirect) + end + end + context "when exception occurs" do before do allow(FileUploader).to receive(:workhorse_authorize).and_raise(SocketError.new) diff --git a/spec/controllers/projects/wikis_controller_spec.rb b/spec/controllers/projects/wikis_controller_spec.rb index 3100aa2cb9651cda6a3d43c01b27b089894ebbed..bfa555aab4c6aa3bbc84b3baec2e74577ecb2961 100644 --- a/spec/controllers/projects/wikis_controller_spec.rb +++ b/spec/controllers/projects/wikis_controller_spec.rb @@ -213,6 +213,7 @@ describe Projects::WikisController do describe 'PATCH #update' do let(:new_title) { 'New title' } let(:new_content) { 'New content' } + subject do patch(:update, params: { diff --git a/spec/controllers/projects_controller_spec.rb b/spec/controllers/projects_controller_spec.rb index 5259c612bbd1dd80796000c6cd0a3d7206bd71a1..9ae1277de26b2ac701c37e8c158318c265e17fcd 100644 --- a/spec/controllers/projects_controller_spec.rb +++ b/spec/controllers/projects_controller_spec.rb @@ -289,6 +289,36 @@ describe ProjectsController do .not_to exceed_query_limit(2).for_query(expected_query) end end + + context 'lfs_blob_ids instance variable' do + let(:project) { create(:project, :public, :repository) } + + before do + sign_in(user) + end + + context 'with vue tree view enabled' do + before do + get :show, params: { namespace_id: project.namespace, id: project } + end + + it 'is not set' do + expect(assigns[:lfs_blob_ids]).to be_nil + end + end + + context 'with vue tree view disabled' do + before do + stub_feature_flags(vue_file_list: false) + + get :show, params: { namespace_id: project.namespace, id: project } + end + + it 'is set' do + expect(assigns[:lfs_blob_ids]).not_to be_nil + end + end + end end describe 'GET edit' do diff --git a/spec/controllers/registrations_controller_spec.rb b/spec/controllers/registrations_controller_spec.rb index da36148ba85d693855055def967fc9990c8df35f..214eb35ec9dc63e9b07f192b1246cf772ee81c1a 100644 --- a/spec/controllers/registrations_controller_spec.rb +++ b/spec/controllers/registrations_controller_spec.rb @@ -306,6 +306,23 @@ describe RegistrationsController do expect(subject.current_user).not_to be_nil end + + context 'with the experimental signup flow enabled and the user is part of the experimental group' do + before do + stub_experiment(signup_flow: true) + stub_experiment_for_user(signup_flow: true) + end + + let(:base_user_params) { { first_name: 'First', last_name: 'Last', username: 'new_username', email: 'new@user.com', password: 'Any_password' } } + + it 'sets name from first and last name' do + post :create, params: { new_user: base_user_params } + + expect(User.last.first_name).to eq(base_user_params[:first_name]) + expect(User.last.last_name).to eq(base_user_params[:last_name]) + expect(User.last.name).to eq("#{base_user_params[:first_name]} #{base_user_params[:last_name]}") + end + end end describe '#destroy' do @@ -395,7 +412,7 @@ describe RegistrationsController do label: anything, property: 'experimental_group' ) - patch :update_registration, params: { user: { name: 'New name', role: 'software_developer', setup_for_company: 'false' } } + patch :update_registration, params: { user: { role: 'software_developer', setup_for_company: 'false' } } end end end diff --git a/spec/controllers/search_controller_spec.rb b/spec/controllers/search_controller_spec.rb index 3dcafae295aa8b256d0162e928dd2428c5278575..ca7b8a4036acde25176edcbc0ad5616dc61a503d 100644 --- a/spec/controllers/search_controller_spec.rb +++ b/spec/controllers/search_controller_spec.rb @@ -92,6 +92,7 @@ describe SearchController do end context 'global search' do + using RSpec::Parameterized::TableSyntax render_views it 'omits pipeline status from load' do @@ -102,6 +103,41 @@ describe SearchController do expect(assigns[:search_objects].first).to eq project end + + context 'check search term length' do + let(:search_queries) do + char_limit = SearchService::SEARCH_CHAR_LIMIT + term_limit = SearchService::SEARCH_TERM_LIMIT + { + chars_under_limit: ('a' * (char_limit - 1)), + chars_over_limit: ('a' * (char_limit + 1)), + terms_under_limit: ('abc ' * (term_limit - 1)), + terms_over_limit: ('abc ' * (term_limit + 1)) + } + end + + where(:string_name, :expectation) do + :chars_under_limit | :not_to_set_flash + :chars_over_limit | :set_chars_flash + :terms_under_limit | :not_to_set_flash + :terms_over_limit | :set_terms_flash + end + + with_them do + it do + get :show, params: { scope: 'projects', search: search_queries[string_name] } + + case expectation + when :not_to_set_flash + expect(controller).not_to set_flash[:alert] + when :set_chars_flash + expect(controller).to set_flash[:alert].to(/characters/) + when :set_terms_flash + expect(controller).to set_flash[:alert].to(/terms/) + end + end + end + end end it 'finds issue comments' do diff --git a/spec/controllers/snippets_controller_spec.rb b/spec/controllers/snippets_controller_spec.rb index 510db4374c05d40097a15ffadea44b723f565b4f..c8f9e4256c975cce30cc78806ccf562fcc18f505 100644 --- a/spec/controllers/snippets_controller_spec.rb +++ b/spec/controllers/snippets_controller_spec.rb @@ -664,4 +664,56 @@ describe SnippetsController do expect(json_response.keys).to match_array(%w(body references)) end end + + describe 'DELETE #destroy' do + let!(:snippet) { create :personal_snippet, author: user } + + context 'when current user has ability to destroy the snippet' do + before do + sign_in(user) + end + + it 'removes the snippet' do + delete :destroy, params: { id: snippet.to_param } + + expect { snippet.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + + context 'when snippet is succesfuly destroyed' do + it 'redirects to the project snippets page' do + delete :destroy, params: { id: snippet.to_param } + + expect(response).to redirect_to(dashboard_snippets_path) + end + end + + context 'when snippet is not destroyed' do + before do + allow(snippet).to receive(:destroy).and_return(false) + controller.instance_variable_set(:@snippet, snippet) + end + + it 'renders the snippet page with errors' do + delete :destroy, params: { id: snippet.to_param } + + expect(flash[:alert]).to eq('Failed to remove snippet.') + expect(response).to redirect_to(snippet_path(snippet)) + end + end + end + + context 'when current_user does not have ability to destroy the snippet' do + let(:another_user) { create(:user) } + + before do + sign_in(another_user) + end + + it 'responds with status 404' do + delete :destroy, params: { id: snippet.to_param } + + expect(response).to have_gitlab_http_status(404) + end + end + end end diff --git a/spec/controllers/users_controller_spec.rb b/spec/controllers/users_controller_spec.rb index bbbb9691f53466b7a4b253f9b82164234d50938e..597d2a185b549463b5a379cd12bad365ceb5a2f3 100644 --- a/spec/controllers/users_controller_spec.rb +++ b/spec/controllers/users_controller_spec.rb @@ -81,6 +81,7 @@ describe UsersController do context 'json with events' do let(:project) { create(:project) } + before do project.add_developer(user) Gitlab::DataBuilder::Push.build_sample(project, user) diff --git a/spec/db/schema_spec.rb b/spec/db/schema_spec.rb index a42916a83a658691455f4450f196e76822c66443..482e0fbe7ce1be13caaee28250c8238b737747e7 100644 --- a/spec/db/schema_spec.rb +++ b/spec/db/schema_spec.rb @@ -34,7 +34,7 @@ describe 'Database schema' do draft_notes: %w[discussion_id commit_id], emails: %w[user_id], events: %w[target_id], - epics: %w[updated_by_id last_edited_by_id start_date_sourcing_milestone_id due_date_sourcing_milestone_id state_id], + epics: %w[updated_by_id last_edited_by_id state_id], forked_project_links: %w[forked_from_project_id], geo_event_log: %w[hashed_storage_attachments_event_id], geo_job_artifact_deleted_events: %w[job_artifact_id], @@ -133,6 +133,7 @@ describe 'Database schema' do 'Ci::BuildTraceChunk' => %w[data_store], 'Ci::JobArtifact' => %w[file_type], 'Ci::Pipeline' => %w[source config_source failure_reason], + 'Ci::Processable' => %w[failure_reason], 'Ci::Runner' => %w[access_level], 'Ci::Stage' => %w[status], 'Clusters::Applications::Ingress' => %w[ingress_type], diff --git a/spec/factories/analytics/cycle_analytics/project_stages.rb b/spec/factories/analytics/cycle_analytics/project_stages.rb index 6f8c140ed8aa7e994f62cfbcbea396200c29749a..3a481bd20fd9dac51c2a3786054cec50a08a101e 100644 --- a/spec/factories/analytics/cycle_analytics/project_stages.rb +++ b/spec/factories/analytics/cycle_analytics/project_stages.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true FactoryBot.define do - factory :cycle_analytics_project_stage, class: Analytics::CycleAnalytics::ProjectStage do + factory :cycle_analytics_project_stage, class: 'Analytics::CycleAnalytics::ProjectStage' do project sequence(:name) { |n| "Stage ##{n}" } hidden { false } diff --git a/spec/factories/aws/roles.rb b/spec/factories/aws/roles.rb index c078033dfadd70c97e5fbfb2f7ee914ca507600d..7195b5713663136c658a79cd7e40b48ebf519aa7 100644 --- a/spec/factories/aws/roles.rb +++ b/spec/factories/aws/roles.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true FactoryBot.define do - factory :aws_role, class: Aws::Role do + factory :aws_role, class: 'Aws::Role' do user role_arn { 'arn:aws:iam::123456789012:role/role-name' } diff --git a/spec/factories/badge.rb b/spec/factories/badge.rb index 1d4e29014cc2e6bdd477bda8e17966878839d77d..7623797a7fa5b80383e0f8ad1d3d3890d63d121e 100644 --- a/spec/factories/badge.rb +++ b/spec/factories/badge.rb @@ -6,11 +6,11 @@ FactoryBot.define do image_url { generate(:url) } end - factory :project_badge, traits: [:base_badge], class: ProjectBadge do + factory :project_badge, traits: [:base_badge], class: 'ProjectBadge' do project end - factory :group_badge, aliases: [:badge], traits: [:base_badge], class: GroupBadge do + factory :group_badge, aliases: [:badge], traits: [:base_badge], class: 'GroupBadge' do group end end diff --git a/spec/factories/chat_names.rb b/spec/factories/chat_names.rb index ace5d5e83c98288e6f4557ae0ec5d9d78e5d62d7..73c885806f2670996c9eba58153d980d91a72a78 100644 --- a/spec/factories/chat_names.rb +++ b/spec/factories/chat_names.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true FactoryBot.define do - factory :chat_name, class: ChatName do + factory :chat_name, class: 'ChatName' do user factory: :user service factory: :service diff --git a/spec/factories/chat_teams.rb b/spec/factories/chat_teams.rb index 52628e6d53d6c6d350a3fef9498981e5f26f26ce..f413555d980f4e71798367a1d15c64468ad2467c 100644 --- a/spec/factories/chat_teams.rb +++ b/spec/factories/chat_teams.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true FactoryBot.define do - factory :chat_team, class: ChatTeam do + factory :chat_team, class: 'ChatTeam' do sequence(:team_id) { |n| "abcdefghijklm#{n}" } namespace factory: :group end diff --git a/spec/factories/ci/bridge.rb b/spec/factories/ci/bridge.rb index 60219b07cf09faceb89d7779753d8a90274dd910..b2e8051eb5ea448c5d259e86cafcf3b1c183490a 100644 --- a/spec/factories/ci/bridge.rb +++ b/spec/factories/ci/bridge.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true FactoryBot.define do - factory :ci_bridge, class: Ci::Bridge do + factory :ci_bridge, class: 'Ci::Bridge' do name { 'bridge' } stage { 'test' } stage_idx { 0 } diff --git a/spec/factories/ci/build_need.rb b/spec/factories/ci/build_need.rb index 568aff45a9105a0259ef059786f1aa4ecb7128bb..fa72e6963434919c5d45a46c834c6e8dd6f44ee1 100644 --- a/spec/factories/ci/build_need.rb +++ b/spec/factories/ci/build_need.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true FactoryBot.define do - factory :ci_build_need, class: Ci::BuildNeed do + factory :ci_build_need, class: 'Ci::BuildNeed' do build factory: :ci_build sequence(:name) { |n| "build_#{n}" } end diff --git a/spec/factories/ci/build_trace_chunks.rb b/spec/factories/ci/build_trace_chunks.rb index 22f091f8e761638cc794d0402159c7be67f6be3a..7c348f4b7e4ce899dead406a8e468e6882796b26 100644 --- a/spec/factories/ci/build_trace_chunks.rb +++ b/spec/factories/ci/build_trace_chunks.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true FactoryBot.define do - factory :ci_build_trace_chunk, class: Ci::BuildTraceChunk do + factory :ci_build_trace_chunk, class: 'Ci::BuildTraceChunk' do build factory: :ci_build chunk_index { 0 } data_store { :redis } diff --git a/spec/factories/ci/build_trace_section_names.rb b/spec/factories/ci/build_trace_section_names.rb index e52694ef3dcc8dd57fe77ac7002068212f4fb8a1..b9b66b4931725e151d14aea93d246f6f0155d42f 100644 --- a/spec/factories/ci/build_trace_section_names.rb +++ b/spec/factories/ci/build_trace_section_names.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true FactoryBot.define do - factory :ci_build_trace_section_name, class: Ci::BuildTraceSectionName do + factory :ci_build_trace_section_name, class: 'Ci::BuildTraceSectionName' do sequence(:name) { |n| "section_#{n}" } project factory: :project end diff --git a/spec/factories/ci/builds.rb b/spec/factories/ci/builds.rb index ecb1f1996d9f9fdd5673ef0edfe9551367a4fef5..3d65f9065bf9e3b8aa0555d65bbd89bd465b9404 100644 --- a/spec/factories/ci/builds.rb +++ b/spec/factories/ci/builds.rb @@ -3,7 +3,7 @@ include ActionDispatch::TestProcess FactoryBot.define do - factory :ci_build, class: Ci::Build do + factory :ci_build, class: 'Ci::Build' do name { 'test' } stage { 'test' } stage_idx { 0 } @@ -77,6 +77,10 @@ FactoryBot.define do status { 'created' } end + trait :waiting_for_resource do + status { 'waiting_for_resource' } + end + trait :preparing do status { 'preparing' } end @@ -207,6 +211,14 @@ FactoryBot.define do trigger_request factory: :ci_trigger_request end + trait :resource_group do + waiting_for_resource_at { 5.minutes.ago } + + after(:build) do |build, evaluator| + build.resource_group = create(:ci_resource_group, project: build.project) + end + end + after(:build) do |build, evaluator| build.project ||= build.pipeline.project end diff --git a/spec/factories/ci/group_variables.rb b/spec/factories/ci/group_variables.rb index 217f05a088e035bd5b11c15a29433ce48d34033a..d3b891eb1e39fef705083d0f7f37a79fe9965932 100644 --- a/spec/factories/ci/group_variables.rb +++ b/spec/factories/ci/group_variables.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true FactoryBot.define do - factory :ci_group_variable, class: Ci::GroupVariable do + factory :ci_group_variable, class: 'Ci::GroupVariable' do sequence(:key) { |n| "VARIABLE_#{n}" } value { 'VARIABLE_VALUE' } masked { false } diff --git a/spec/factories/ci/job_artifacts.rb b/spec/factories/ci/job_artifacts.rb index bdc6cc2f169843e9f6cbd9e5a8839c34569f7a87..7347c2b87cafcb8f193d6a32ab4dffe05bf7f5b7 100644 --- a/spec/factories/ci/job_artifacts.rb +++ b/spec/factories/ci/job_artifacts.rb @@ -3,7 +3,7 @@ include ActionDispatch::TestProcess FactoryBot.define do - factory :ci_job_artifact, class: Ci::JobArtifact do + factory :ci_job_artifact, class: 'Ci::JobArtifact' do job factory: :ci_build file_type { :archive } file_format { :zip } diff --git a/spec/factories/ci/job_variables.rb b/spec/factories/ci/job_variables.rb index bfc631b8126aac18de0b628a963ccace9f09ea3b..472a89d3bef2e04cda1f700fdd4f9d54ecf92da8 100644 --- a/spec/factories/ci/job_variables.rb +++ b/spec/factories/ci/job_variables.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true FactoryBot.define do - factory :ci_job_variable, class: Ci::JobVariable do + factory :ci_job_variable, class: 'Ci::JobVariable' do sequence(:key) { |n| "VARIABLE_#{n}" } value { 'VARIABLE_VALUE' } diff --git a/spec/factories/ci/pipeline_schedule.rb b/spec/factories/ci/pipeline_schedule.rb index c752dc1c9dd7a38c7c73fd1b99debfcae1226639..fc9044fb8e3f81f85d46f7444b34dfd92df04d16 100644 --- a/spec/factories/ci/pipeline_schedule.rb +++ b/spec/factories/ci/pipeline_schedule.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true FactoryBot.define do - factory :ci_pipeline_schedule, class: Ci::PipelineSchedule do + factory :ci_pipeline_schedule, class: 'Ci::PipelineSchedule' do cron { '0 1 * * *' } cron_timezone { Gitlab::Ci::CronParser::VALID_SYNTAX_SAMPLE_TIME_ZONE } ref { 'master' } diff --git a/spec/factories/ci/pipeline_schedule_variables.rb b/spec/factories/ci/pipeline_schedule_variables.rb index 24913c614f4fdbd3137a6a28438c20fa6a82d72a..d598ba1b1b9d05a9ca4a9b4d3345277ac1f97b6d 100644 --- a/spec/factories/ci/pipeline_schedule_variables.rb +++ b/spec/factories/ci/pipeline_schedule_variables.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true FactoryBot.define do - factory :ci_pipeline_schedule_variable, class: Ci::PipelineScheduleVariable do + factory :ci_pipeline_schedule_variable, class: 'Ci::PipelineScheduleVariable' do sequence(:key) { |n| "VARIABLE_#{n}" } value { 'VARIABLE_VALUE' } variable_type { 'env_var' } diff --git a/spec/factories/ci/pipeline_variables.rb b/spec/factories/ci/pipeline_variables.rb index 48f6e35fe70b3b792a78cbc346e8848811515a7f..17aa9962e0b81b5820eb3a30f8cbbea8df83e70b 100644 --- a/spec/factories/ci/pipeline_variables.rb +++ b/spec/factories/ci/pipeline_variables.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true FactoryBot.define do - factory :ci_pipeline_variable, class: Ci::PipelineVariable do + factory :ci_pipeline_variable, class: 'Ci::PipelineVariable' do sequence(:key) { |n| "VARIABLE_#{n}" } value { 'VARIABLE_VALUE' } diff --git a/spec/factories/ci/pipelines.rb b/spec/factories/ci/pipelines.rb index e2ec9d496bc42b8522b9ff9a31d0454f5ddbaa44..afc203562ba1b5e6c9d6ee057ae43253a70825dc 100644 --- a/spec/factories/ci/pipelines.rb +++ b/spec/factories/ci/pipelines.rb @@ -2,7 +2,7 @@ FactoryBot.define do # TODO: we can remove this factory in favour of :ci_pipeline - factory :ci_empty_pipeline, class: Ci::Pipeline do + factory :ci_empty_pipeline, class: 'Ci::Pipeline' do source { :push } ref { 'master' } sha { '97de212e80737a608d939f648d959671fb0a0142' } diff --git a/spec/factories/ci/resource.rb b/spec/factories/ci/resource.rb new file mode 100644 index 0000000000000000000000000000000000000000..515329506e5f8c62f75e2ae879d7c04ec7812138 --- /dev/null +++ b/spec/factories/ci/resource.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :ci_resource, class: 'Ci::Resource' do + resource_group factory: :ci_resource_group + + trait(:retained) do + build factory: :ci_build + end + end +end diff --git a/spec/factories/ci/resource_group.rb b/spec/factories/ci/resource_group.rb new file mode 100644 index 0000000000000000000000000000000000000000..7ca890371601d4947bac9aa15fe389a21e59a80e --- /dev/null +++ b/spec/factories/ci/resource_group.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :ci_resource_group, class: 'Ci::ResourceGroup' do + project + sequence(:key) { |n| "IOS_#{n}" } + end +end diff --git a/spec/factories/ci/runner_projects.rb b/spec/factories/ci/runner_projects.rb index bc28544a839d2ea05c7c48afd6ceb1353948cbf9..ead9fe10f6e344e3e2746d39ef4024380be73fd4 100644 --- a/spec/factories/ci/runner_projects.rb +++ b/spec/factories/ci/runner_projects.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true FactoryBot.define do - factory :ci_runner_project, class: Ci::RunnerProject do + factory :ci_runner_project, class: 'Ci::RunnerProject' do runner factory: [:ci_runner, :project] project end diff --git a/spec/factories/ci/runners.rb b/spec/factories/ci/runners.rb index 3697970721b845043efcde352eacebf40787dc05..30f78531324984cec74d7c53e81edf36fd191bc4 100644 --- a/spec/factories/ci/runners.rb +++ b/spec/factories/ci/runners.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true FactoryBot.define do - factory :ci_runner, class: Ci::Runner do + factory :ci_runner, class: 'Ci::Runner' do sequence(:description) { |n| "My runner#{n}" } platform { "darwin" } diff --git a/spec/factories/ci/sources/pipelines.rb b/spec/factories/ci/sources/pipelines.rb index 57495502944d53e75db617ca9871931b31bb3392..93d35097eac13b4ec04221ae6df52abc15d0cace 100644 --- a/spec/factories/ci/sources/pipelines.rb +++ b/spec/factories/ci/sources/pipelines.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true FactoryBot.define do - factory :ci_sources_pipeline, class: Ci::Sources::Pipeline do + factory :ci_sources_pipeline, class: 'Ci::Sources::Pipeline' do after(:build) do |source| source.project ||= source.pipeline.project source.source_pipeline ||= source.source_job.pipeline diff --git a/spec/factories/ci/stages.rb b/spec/factories/ci/stages.rb index 67f4db41d965975b5f92fb6e661e0d99142419f6..4751c04584e736d287072d212f86e1ff34ac1d2d 100644 --- a/spec/factories/ci/stages.rb +++ b/spec/factories/ci/stages.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true FactoryBot.define do - factory :ci_stage, class: Ci::LegacyStage do + factory :ci_stage, class: 'Ci::LegacyStage' do skip_create transient do @@ -18,7 +18,7 @@ FactoryBot.define do end end - factory :ci_stage_entity, class: Ci::Stage do + factory :ci_stage_entity, class: 'Ci::Stage' do project factory: :project pipeline factory: :ci_empty_pipeline diff --git a/spec/factories/ci/trigger_requests.rb b/spec/factories/ci/trigger_requests.rb index d63bf9868c9fd21c8d4ed753d5da2066b11b11a3..cfffcf222f3656e757449e6a4b049ad51d1f916b 100644 --- a/spec/factories/ci/trigger_requests.rb +++ b/spec/factories/ci/trigger_requests.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true FactoryBot.define do - factory :ci_trigger_request, class: Ci::TriggerRequest do + factory :ci_trigger_request, class: 'Ci::TriggerRequest' do trigger factory: :ci_trigger end end diff --git a/spec/factories/ci/triggers.rb b/spec/factories/ci/triggers.rb index 6f628ed54353e8aa094982469a2fee698efaa5e3..5089d43f6ff6d5e402776b941bbdf73131c5bdca 100644 --- a/spec/factories/ci/triggers.rb +++ b/spec/factories/ci/triggers.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true FactoryBot.define do - factory :ci_trigger_without_token, class: Ci::Trigger do + factory :ci_trigger_without_token, class: 'Ci::Trigger' do owner factory :ci_trigger do diff --git a/spec/factories/ci/variables.rb b/spec/factories/ci/variables.rb index 9d2501c4e1838eb206650b77cea59f103ff5ac7a..a4cbf873b0bd9ef70878b5e2620b524a261d61cf 100644 --- a/spec/factories/ci/variables.rb +++ b/spec/factories/ci/variables.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true FactoryBot.define do - factory :ci_variable, class: Ci::Variable do + factory :ci_variable, class: 'Ci::Variable' do sequence(:key) { |n| "VARIABLE_#{n}" } value { 'VARIABLE_VALUE' } masked { false } diff --git a/spec/factories/clusters/applications/helm.rb b/spec/factories/clusters/applications/helm.rb index 0e59f8cb9ec51bb13b574b77ba2f0c20393454af..ff9fc882dcc50dda83ed1c5fe9f2e7f599726f9d 100644 --- a/spec/factories/clusters/applications/helm.rb +++ b/spec/factories/clusters/applications/helm.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true FactoryBot.define do - factory :clusters_applications_helm, class: Clusters::Applications::Helm do + factory :clusters_applications_helm, class: 'Clusters::Applications::Helm' do cluster factory: %i(cluster provided_by_gcp) before(:create) do @@ -70,39 +70,40 @@ FactoryBot.define do updated_at { ClusterWaitForAppInstallationWorker::TIMEOUT.ago } end - factory :clusters_applications_ingress, class: Clusters::Applications::Ingress do + factory :clusters_applications_ingress, class: 'Clusters::Applications::Ingress' do + modsecurity_enabled { false } cluster factory: %i(cluster with_installed_helm provided_by_gcp) end - factory :clusters_applications_cert_manager, class: Clusters::Applications::CertManager do + factory :clusters_applications_cert_manager, class: 'Clusters::Applications::CertManager' do email { 'admin@example.com' } cluster factory: %i(cluster with_installed_helm provided_by_gcp) end - factory :clusters_applications_elastic_stack, class: Clusters::Applications::ElasticStack do + factory :clusters_applications_elastic_stack, class: 'Clusters::Applications::ElasticStack' do cluster factory: %i(cluster with_installed_helm provided_by_gcp) end - factory :clusters_applications_crossplane, class: Clusters::Applications::Crossplane do + factory :clusters_applications_crossplane, class: 'Clusters::Applications::Crossplane' do stack { 'gcp' } cluster factory: %i(cluster with_installed_helm provided_by_gcp) end - factory :clusters_applications_prometheus, class: Clusters::Applications::Prometheus do + factory :clusters_applications_prometheus, class: 'Clusters::Applications::Prometheus' do cluster factory: %i(cluster with_installed_helm provided_by_gcp) end - factory :clusters_applications_runner, class: Clusters::Applications::Runner do + factory :clusters_applications_runner, class: 'Clusters::Applications::Runner' do runner factory: %i(ci_runner) cluster factory: %i(cluster with_installed_helm provided_by_gcp) end - factory :clusters_applications_knative, class: Clusters::Applications::Knative do + factory :clusters_applications_knative, class: 'Clusters::Applications::Knative' do hostname { 'example.com' } cluster factory: %i(cluster with_installed_helm provided_by_gcp) end - factory :clusters_applications_jupyter, class: Clusters::Applications::Jupyter do + factory :clusters_applications_jupyter, class: 'Clusters::Applications::Jupyter' do oauth_application factory: :oauth_application cluster factory: %i(cluster with_installed_helm provided_by_gcp project) end diff --git a/spec/factories/clusters/clusters.rb b/spec/factories/clusters/clusters.rb index 7121850e5ffecd25a6bab07ca8be63cd51d0230f..843f87ef7d696e8db98dacd28e26d789c9d4ed68 100644 --- a/spec/factories/clusters/clusters.rb +++ b/spec/factories/clusters/clusters.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true FactoryBot.define do - factory :cluster, class: Clusters::Cluster do + factory :cluster, class: 'Clusters::Cluster' do user name { 'test-cluster' } cluster_type { :project_type } diff --git a/spec/factories/clusters/kubernetes_namespaces.rb b/spec/factories/clusters/kubernetes_namespaces.rb index 75895e1c020f872549d570e8c09d4af4666f6cec..c820bf4da60ba1b7a410412914ae262e7b46f96d 100644 --- a/spec/factories/clusters/kubernetes_namespaces.rb +++ b/spec/factories/clusters/kubernetes_namespaces.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true FactoryBot.define do - factory :cluster_kubernetes_namespace, class: Clusters::KubernetesNamespace do + factory :cluster_kubernetes_namespace, class: 'Clusters::KubernetesNamespace' do association :cluster, :project, :provided_by_gcp after(:build) do |kubernetes_namespace| diff --git a/spec/factories/clusters/platforms/kubernetes.rb b/spec/factories/clusters/platforms/kubernetes.rb index dbcb838e9dacaa0d6924456dea72ca05f669101d..822457adaefb47f0390db4426a722299f8b9e5bf 100644 --- a/spec/factories/clusters/platforms/kubernetes.rb +++ b/spec/factories/clusters/platforms/kubernetes.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true FactoryBot.define do - factory :cluster_platform_kubernetes, class: Clusters::Platforms::Kubernetes do + factory :cluster_platform_kubernetes, class: 'Clusters::Platforms::Kubernetes' do association :cluster, platform_type: :kubernetes, provider_type: :user namespace { nil } api_url { 'https://kubernetes.example.com' } diff --git a/spec/factories/clusters/projects.rb b/spec/factories/clusters/projects.rb index 6cda77c6f85fda718fe2834f8f0ba91e54b4f516..e980279cad90dfb1def500144613b08dd4a1d527 100644 --- a/spec/factories/clusters/projects.rb +++ b/spec/factories/clusters/projects.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true FactoryBot.define do - factory :cluster_project, class: Clusters::Project do + factory :cluster_project, class: 'Clusters::Project' do cluster project end diff --git a/spec/factories/clusters/providers/aws.rb b/spec/factories/clusters/providers/aws.rb index e4b10aa5f33b3ac63996685e0ba5afa97d7cdacd..2c54300e606301d66e849000b9bf71d5c5bfb7bd 100644 --- a/spec/factories/clusters/providers/aws.rb +++ b/spec/factories/clusters/providers/aws.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true FactoryBot.define do - factory :cluster_provider_aws, class: Clusters::Providers::Aws do + factory :cluster_provider_aws, class: 'Clusters::Providers::Aws' do association :cluster, platform_type: :kubernetes, provider_type: :aws role_arn { 'arn:aws:iam::123456789012:role/role-name' } diff --git a/spec/factories/clusters/providers/gcp.rb b/spec/factories/clusters/providers/gcp.rb index 216c4d4fa31164e0a4c5be4f18e623bdf31decb9..c99f4407b42783b8cd2b95e770a826d1d262ddf9 100644 --- a/spec/factories/clusters/providers/gcp.rb +++ b/spec/factories/clusters/providers/gcp.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true FactoryBot.define do - factory :cluster_provider_gcp, class: Clusters::Providers::Gcp do + factory :cluster_provider_gcp, class: 'Clusters::Providers::Gcp' do association :cluster, platform_type: :kubernetes, provider_type: :gcp gcp_project_id { 'test-gcp-project' } diff --git a/spec/factories/commit_statuses.rb b/spec/factories/commit_statuses.rb index 5d635d93ff2e786e15f75dc171c4b2fc232a0093..fa10b37cdbf79039bcf07246113d159ca3997133 100644 --- a/spec/factories/commit_statuses.rb +++ b/spec/factories/commit_statuses.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true FactoryBot.define do - factory :commit_status, class: CommitStatus do + factory :commit_status, class: 'CommitStatus' do name { 'default' } stage { 'test' } stage_idx { 0 } @@ -35,6 +35,10 @@ FactoryBot.define do status { 'pending' } end + trait :waiting_for_resource do + status { 'waiting_for_resource' } + end + trait :preparing do status { 'preparing' } end @@ -55,7 +59,7 @@ FactoryBot.define do build.project = build.pipeline.project end - factory :generic_commit_status, class: GenericCommitStatus do + factory :generic_commit_status, class: 'GenericCommitStatus' do name { 'generic' } description { 'external commit status' } end diff --git a/spec/factories/container_expiration_policies.rb b/spec/factories/container_expiration_policies.rb new file mode 100644 index 0000000000000000000000000000000000000000..951127a4aa729d6e76e20b3d70266108c80d496a --- /dev/null +++ b/spec/factories/container_expiration_policies.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :container_expiration_policy, class: 'ContainerExpirationPolicy' do + association :project, factory: [:project, :without_container_expiration_policy] + cadence { '1d' } + enabled { true } + + trait :runnable do + after(:create) do |policy| + # next_run_at will be set before_save to Time.now + cadence, so this ensures the policy is active + policy.update_column(:next_run_at, Time.zone.now - 1.day) + end + end + + trait :disabled do + enabled { false } + end + end +end diff --git a/spec/factories/deployments.rb b/spec/factories/deployments.rb index f8738d28d833473c93e8bb7ccd5ee3f958832709..f92e213a3858e70b1e058650da422b72bdb05905 100644 --- a/spec/factories/deployments.rb +++ b/spec/factories/deployments.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true FactoryBot.define do - factory :deployment, class: Deployment do + factory :deployment, class: 'Deployment' do sha { 'b83d6e391c22777fca1ed3012fce84f633d7fed0' } ref { 'master' } tag { false } diff --git a/spec/factories/dev_ops_score_metrics.rb b/spec/factories/dev_ops_score_metrics.rb index 0d9d7059e7fd71cab9a004b65e8aea4a3fa61562..1d1f1a2c39e6affe20ecea6efba61ce9e39b581e 100644 --- a/spec/factories/dev_ops_score_metrics.rb +++ b/spec/factories/dev_ops_score_metrics.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true FactoryBot.define do - factory :dev_ops_score_metric, class: DevOpsScore::Metric do + factory :dev_ops_score_metric, class: 'DevOpsScore::Metric' do leader_issues { 9.256 } instance_issues { 1.234 } percentage_issues { 13.331 } diff --git a/spec/factories/environments.rb b/spec/factories/environments.rb index 44aa4527e125f1f4002bef8f526a66a6501b79ba..323ea2d478b6ebebf10bebf3ea9650213f730a9c 100644 --- a/spec/factories/environments.rb +++ b/spec/factories/environments.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true FactoryBot.define do - factory :environment, class: Environment do + factory :environment, class: 'Environment' do sequence(:name) { |n| "environment#{n}" } association :project, :repository diff --git a/spec/factories/error_tracking/detailed_error.rb b/spec/factories/error_tracking/detailed_error.rb index f12c327d403be8c7a78c54ee5a0aa2b8cf04380f..07b6c53e3cddc8d9b48ed4b38b430b1cd40a2785 100644 --- a/spec/factories/error_tracking/detailed_error.rb +++ b/spec/factories/error_tracking/detailed_error.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true FactoryBot.define do - factory :detailed_error_tracking_error, class: Gitlab::ErrorTracking::DetailedError do + factory :detailed_error_tracking_error, class: 'Gitlab::ErrorTracking::DetailedError' do id { '1' } title { 'title' } type { 'error' } @@ -18,6 +18,12 @@ FactoryBot.define do project_slug { 'project_name' } short_id { 'ID' } status { 'unresolved' } + tags do + { + level: 'error', + logger: 'rails' + } + end frequency do [ [Time.now.to_i, 10] @@ -28,6 +34,7 @@ FactoryBot.define do last_release_last_commit { '9ad419c86' } first_release_short_version { 'abc123' } last_release_short_version { 'abc123' } + first_release_version { '12345678' } skip_create end diff --git a/spec/factories/error_tracking/error.rb b/spec/factories/error_tracking/error.rb index 541bc410462a26e9c39ca8eb08ea550b764edff7..5be1f074555ab3651056e7c59a9f57907098b45a 100644 --- a/spec/factories/error_tracking/error.rb +++ b/spec/factories/error_tracking/error.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true FactoryBot.define do - factory :error_tracking_error, class: Gitlab::ErrorTracking::Error do + factory :error_tracking_error, class: 'Gitlab::ErrorTracking::Error' do id { 'id' } title { 'title' } type { 'error' } diff --git a/spec/factories/error_tracking/error_event.rb b/spec/factories/error_tracking/error_event.rb index 1590095f1bdd2c28393e8a59fc4ae2b110ee63b8..880fdf17faeb8a6c019658c0d8ade1c12d98530e 100644 --- a/spec/factories/error_tracking/error_event.rb +++ b/spec/factories/error_tracking/error_event.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true FactoryBot.define do - factory :error_tracking_error_event, class: Gitlab::ErrorTracking::ErrorEvent do + factory :error_tracking_error_event, class: 'Gitlab::ErrorTracking::ErrorEvent' do issue_id { 'id' } date_received { Time.now.iso8601 } stack_trace_entries do diff --git a/spec/factories/error_tracking/project.rb b/spec/factories/error_tracking/project.rb index 885d398d4332adbfe6f4e2a890b019f8a4bdd56b..4cbec312622605c0f7c1a51d7ba1bf6cbae084d4 100644 --- a/spec/factories/error_tracking/project.rb +++ b/spec/factories/error_tracking/project.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true FactoryBot.define do - factory :error_tracking_project, class: Gitlab::ErrorTracking::Project do + factory :error_tracking_project, class: 'Gitlab::ErrorTracking::Project' do id { '1' } name { 'Sentry Example' } slug { 'sentry-example' } diff --git a/spec/factories/events.rb b/spec/factories/events.rb index 4eedcd02c9a0521e2827898072513ec4f0333953..81d57a2505882d6e889a1255db761a551a17b4eb 100644 --- a/spec/factories/events.rb +++ b/spec/factories/events.rb @@ -24,7 +24,7 @@ FactoryBot.define do end end - factory :push_event, class: PushEvent do + factory :push_event, class: 'PushEvent' do project factory: :project_empty_repo author(factory: :user) { project.creator } action { Event::PUSHED } diff --git a/spec/factories/gitaly/commit.rb b/spec/factories/gitaly/commit.rb index ef5301db7704730e4f612cb568aab445d2dfd3fb..2ed201e9aacf952cc2bfed898688de05d0f7f11a 100644 --- a/spec/factories/gitaly/commit.rb +++ b/spec/factories/gitaly/commit.rb @@ -3,7 +3,7 @@ FactoryBot.define do sequence(:gitaly_commit_id) { Digest::SHA1.hexdigest(Time.now.to_f.to_s) } - factory :gitaly_commit, class: Gitaly::GitCommit do + factory :gitaly_commit, class: 'Gitaly::GitCommit' do skip_create id { generate(:gitaly_commit_id) } diff --git a/spec/factories/gitaly/commit_author.rb b/spec/factories/gitaly/commit_author.rb index 51dcd8a623b768ae5f11177305856196331598c5..31097118d1f68d9d8517caffb149d0e18c181e87 100644 --- a/spec/factories/gitaly/commit_author.rb +++ b/spec/factories/gitaly/commit_author.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true FactoryBot.define do - factory :gitaly_commit_author, class: Gitaly::CommitAuthor do + factory :gitaly_commit_author, class: 'Gitaly::CommitAuthor' do skip_create name { generate(:name) } diff --git a/spec/factories/gitaly/tag.rb b/spec/factories/gitaly/tag.rb index a7a84753090cc9dbaa8c2cbe8065ddf3ab11329e..9dd1b8301c1bdca8adb370300194cdab526329ea 100644 --- a/spec/factories/gitaly/tag.rb +++ b/spec/factories/gitaly/tag.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true FactoryBot.define do - factory :gitaly_tag, class: Gitaly::Tag do + factory :gitaly_tag, class: 'Gitaly::Tag' do skip_create name { 'v3.1.4' } diff --git a/spec/factories/grafana_integrations.rb b/spec/factories/grafana_integrations.rb index ae819ca828c1d883014b60a0590d6e1fc77e9815..a647ef8d2ecc83d4b13c0ee993497e5a6bef00f0 100644 --- a/spec/factories/grafana_integrations.rb +++ b/spec/factories/grafana_integrations.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true FactoryBot.define do - factory :grafana_integration, class: GrafanaIntegration do + factory :grafana_integration, class: 'GrafanaIntegration' do project grafana_url { 'https://grafana.example.com' } token { SecureRandom.hex(10) } diff --git a/spec/factories/groups.rb b/spec/factories/groups.rb index 93c01f8034d3705bc49fb71397cf7ac98c906773..4b6c1756d1ec9ae1a2f5c7aa217b0e071e2e3752 100644 --- a/spec/factories/groups.rb +++ b/spec/factories/groups.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true FactoryBot.define do - factory :group, class: Group, parent: :namespace do + factory :group, class: 'Group', parent: :namespace do sequence(:name) { |n| "group#{n}" } path { name.downcase.gsub(/\s/, '_') } type { 'Group' } diff --git a/spec/factories/import_states.rb b/spec/factories/import_states.rb index 576f68ab57f5ef707da28599c1093440cbc49512..4dca78b1059f6d769dc5ed01ea4a9c3d7f3dcb0c 100644 --- a/spec/factories/import_states.rb +++ b/spec/factories/import_states.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true FactoryBot.define do - factory :import_state, class: ProjectImportState do + factory :import_state, class: 'ProjectImportState' do status { :none } association :project, factory: :project diff --git a/spec/factories/labels.rb b/spec/factories/labels.rb index 89fcd8b1a9d9c05c7ba604eef41c6382b3c3c0df..81d3e4be6fd4bcb78b6b6a61e35a6ff1cff8e490 100644 --- a/spec/factories/labels.rb +++ b/spec/factories/labels.rb @@ -6,7 +6,7 @@ FactoryBot.define do color { "#990000" } end - factory :label, traits: [:base_label], class: ProjectLabel do + factory :label, traits: [:base_label], class: 'ProjectLabel' do project transient do diff --git a/spec/factories/namespace/aggregation_schedules.rb b/spec/factories/namespace/aggregation_schedules.rb index c172c3360e299c8b700e0afc369fb8ecf3d5ac03..5962c46dee66f2a49c1b4a96dca3fcaa081e197a 100644 --- a/spec/factories/namespace/aggregation_schedules.rb +++ b/spec/factories/namespace/aggregation_schedules.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true FactoryBot.define do - factory :namespace_aggregation_schedules, class: Namespace::AggregationSchedule do + factory :namespace_aggregation_schedules, class: 'Namespace::AggregationSchedule' do namespace end end diff --git a/spec/factories/namespace/root_storage_statistics.rb b/spec/factories/namespace/root_storage_statistics.rb index 54c5921eb44aaa68f0e80b47ecccb8907da15a57..3b11d7a6ec72980be2751ff7961a89bcdd8288d1 100644 --- a/spec/factories/namespace/root_storage_statistics.rb +++ b/spec/factories/namespace/root_storage_statistics.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true FactoryBot.define do - factory :namespace_root_storage_statistics, class: Namespace::RootStorageStatistics do + factory :namespace_root_storage_statistics, class: 'Namespace::RootStorageStatistics' do namespace end end diff --git a/spec/factories/notes.rb b/spec/factories/notes.rb index 330f52764221b76e232f4325d5aacfe9a7e4fe37..11fc5060cf0df1d54de8be7c9ba88c20f451f0f3 100644 --- a/spec/factories/notes.rb +++ b/spec/factories/notes.rb @@ -18,9 +18,9 @@ FactoryBot.define do factory :note_on_personal_snippet, traits: [:on_personal_snippet] factory :system_note, traits: [:system] - factory :discussion_note, class: DiscussionNote + factory :discussion_note, class: 'DiscussionNote' - factory :discussion_note_on_merge_request, traits: [:on_merge_request], class: DiscussionNote do + factory :discussion_note_on_merge_request, traits: [:on_merge_request], class: 'DiscussionNote' do association :project, :repository trait :resolved do @@ -29,22 +29,22 @@ FactoryBot.define do end end - factory :discussion_note_on_issue, traits: [:on_issue], class: DiscussionNote + factory :discussion_note_on_issue, traits: [:on_issue], class: 'DiscussionNote' - factory :discussion_note_on_commit, traits: [:on_commit], class: DiscussionNote + factory :discussion_note_on_commit, traits: [:on_commit], class: 'DiscussionNote' - factory :discussion_note_on_personal_snippet, traits: [:on_personal_snippet], class: DiscussionNote + factory :discussion_note_on_personal_snippet, traits: [:on_personal_snippet], class: 'DiscussionNote' - factory :discussion_note_on_snippet, traits: [:on_snippet], class: DiscussionNote + factory :discussion_note_on_snippet, traits: [:on_snippet], class: 'DiscussionNote' - factory :legacy_diff_note_on_commit, traits: [:on_commit, :legacy_diff_note], class: LegacyDiffNote + factory :legacy_diff_note_on_commit, traits: [:on_commit, :legacy_diff_note], class: 'LegacyDiffNote' - factory :legacy_diff_note_on_merge_request, traits: [:on_merge_request, :legacy_diff_note], class: LegacyDiffNote do + factory :legacy_diff_note_on_merge_request, traits: [:on_merge_request, :legacy_diff_note], class: 'LegacyDiffNote' do association :project, :repository position { '' } end - factory :diff_note_on_merge_request, traits: [:on_merge_request], class: DiffNote do + factory :diff_note_on_merge_request, traits: [:on_merge_request], class: 'DiffNote' do association :project, :repository transient do @@ -95,7 +95,7 @@ FactoryBot.define do end end - factory :diff_note_on_commit, traits: [:on_commit], class: DiffNote do + factory :diff_note_on_commit, traits: [:on_commit], class: 'DiffNote' do association :project, :repository transient do diff --git a/spec/factories/project_error_tracking_settings.rb b/spec/factories/project_error_tracking_settings.rb index f90a2d17846139ae95b5ebf69a6cf7b0253dbc8d..7af881f4214c0887de67a992f53b66e9e63f3834 100644 --- a/spec/factories/project_error_tracking_settings.rb +++ b/spec/factories/project_error_tracking_settings.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true FactoryBot.define do - factory :project_error_tracking_setting, class: ErrorTracking::ProjectErrorTrackingSetting do + factory :project_error_tracking_setting, class: 'ErrorTracking::ProjectErrorTrackingSetting' do project api_url { 'https://gitlab.com/api/0/projects/sentry-org/sentry-project' } enabled { true } diff --git a/spec/factories/project_metrics_settings.rb b/spec/factories/project_metrics_settings.rb index 51b2ce0e0e9825f9ca90a1eb2481bc55f79f519e..b5c0fd88a6ca254a61a3b55aecf007e3da3723be 100644 --- a/spec/factories/project_metrics_settings.rb +++ b/spec/factories/project_metrics_settings.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true FactoryBot.define do - factory :project_metrics_setting, class: ProjectMetricsSetting do + factory :project_metrics_setting, class: 'ProjectMetricsSetting' do project external_dashboard_url { 'https://grafana.com' } end diff --git a/spec/factories/projects.rb b/spec/factories/projects.rb index 2608f717f1cc00153c691d9e0aa93e3778a6ac1d..490ae9e84e76e872b145300a3e512c71493f4dad 100644 --- a/spec/factories/projects.rb +++ b/spec/factories/projects.rb @@ -25,6 +25,7 @@ FactoryBot.define do builds_access_level { ProjectFeature::ENABLED } snippets_access_level { ProjectFeature::ENABLED } issues_access_level { ProjectFeature::ENABLED } + forking_access_level { ProjectFeature::ENABLED } merge_requests_access_level { ProjectFeature::ENABLED } repository_access_level { ProjectFeature::ENABLED } pages_access_level do @@ -48,6 +49,7 @@ FactoryBot.define do builds_access_level: builds_access_level, snippets_access_level: evaluator.snippets_access_level, issues_access_level: evaluator.issues_access_level, + forking_access_level: evaluator.forking_access_level, merge_requests_access_level: merge_requests_access_level, repository_access_level: evaluator.repository_access_level } @@ -137,6 +139,12 @@ FactoryBot.define do end end + trait :without_container_expiration_policy do + after(:build) do |project| + project.class.skip_callback(:create, :after, :create_container_expiration_policy, raise: false) + end + end + # Build a custom repository by specifying a hash of `filename => content` in # the transient `files` attribute. Each file will be created in its own # commit, operating against the master branch. So, the following call: @@ -258,6 +266,9 @@ FactoryBot.define do trait(:issues_disabled) { issues_access_level { ProjectFeature::DISABLED } } trait(:issues_enabled) { issues_access_level { ProjectFeature::ENABLED } } trait(:issues_private) { issues_access_level { ProjectFeature::PRIVATE } } + trait(:forking_disabled) { forking_access_level { ProjectFeature::DISABLED } } + trait(:forking_enabled) { forking_access_level { ProjectFeature::ENABLED } } + trait(:forking_private) { forking_access_level { ProjectFeature::PRIVATE } } trait(:merge_requests_enabled) { merge_requests_access_level { ProjectFeature::ENABLED } } trait(:merge_requests_disabled) { merge_requests_access_level { ProjectFeature::DISABLED } } trait(:merge_requests_private) { merge_requests_access_level { ProjectFeature::PRIVATE } } diff --git a/spec/factories/prometheus_metrics.rb b/spec/factories/prometheus_metrics.rb index f6b58cf84c3479cebfd5eeac0f5687e7e3282a7b..83e3845f1c3dd2cdb3ae2d2f4c6ccfef703d4388 100644 --- a/spec/factories/prometheus_metrics.rb +++ b/spec/factories/prometheus_metrics.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true FactoryBot.define do - factory :prometheus_metric, class: PrometheusMetric do + factory :prometheus_metric, class: 'PrometheusMetric' do title { 'title' } query { 'avg(metric)' } y_label { 'y_label' } diff --git a/spec/factories/releases.rb b/spec/factories/releases.rb index 182ee2378d433f533e14068a4b27391399595dd9..0e79f2e6d3a3e8dc94ce18fa97fed8d4bb54f185 100644 --- a/spec/factories/releases.rb +++ b/spec/factories/releases.rb @@ -20,5 +20,14 @@ FactoryBot.define do create(:evidence, release: release) end end + + trait :with_milestones do + transient do + milestones_count { 2 } + end + after(:create) do |release, evaluator| + create_list(:milestone, evaluator.milestones_count, project: evaluator.project, releases: [release]) + end + end end end diff --git a/spec/factories/releases/link.rb b/spec/factories/releases/link.rb index d23db6d4badf8484ee8394ebf21b9e7fca281690..82446dbdb692c6634ec9cab265b1be25d79ba416 100644 --- a/spec/factories/releases/link.rb +++ b/spec/factories/releases/link.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true FactoryBot.define do - factory :release_link, class: ::Releases::Link do + factory :release_link, class: '::Releases::Link' do release sequence(:name) { |n| "release-18.#{n}.dmg" } sequence(:url) { |n| "https://example.com/scrambled-url/app-#{n}.zip" } diff --git a/spec/factories/resource_weight_events.rb b/spec/factories/resource_weight_events.rb new file mode 100644 index 0000000000000000000000000000000000000000..cb9a34df332e10a3c4d999cff1c3eb917eb77bbf --- /dev/null +++ b/spec/factories/resource_weight_events.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :resource_weight_event do + issue { create(:issue) } + user { issue&.author || create(:user) } + end +end diff --git a/spec/factories/sentry_issue.rb b/spec/factories/sentry_issue.rb index c9886f1673a03b264bb053d29b37d64f64092e87..e729095432cd5d14bb2143df1206f692ddfb4e05 100644 --- a/spec/factories/sentry_issue.rb +++ b/spec/factories/sentry_issue.rb @@ -1,8 +1,8 @@ # frozen_string_literal: true FactoryBot.define do - factory :sentry_issue, class: SentryIssue do + factory :sentry_issue, class: 'SentryIssue' do issue - sentry_issue_identifier { 1234567891 } + sequence(:sentry_issue_identifier) { |n| 10000000 + n } end end diff --git a/spec/factories/serverless/domain_cluster.rb b/spec/factories/serverless/domain_cluster.rb index 290d3fc152e07d3e5fa6dc920f3d2bc38dc5fe7d..5adfcacbd7fc96905b874095960ffb324f6a50e1 100644 --- a/spec/factories/serverless/domain_cluster.rb +++ b/spec/factories/serverless/domain_cluster.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true FactoryBot.define do - factory :serverless_domain_cluster, class: Serverless::DomainCluster do + factory :serverless_domain_cluster, class: 'Serverless::DomainCluster' do pages_domain { create(:pages_domain) } knative { create(:clusters_applications_knative) } creator { create(:user) } diff --git a/spec/factories/services.rb b/spec/factories/services.rb index b6bb30d1f933e0621388e480090f364d368af2e4..5d62b3cb9c94cbe15ba907519ca4106db66915c8 100644 --- a/spec/factories/services.rb +++ b/spec/factories/services.rb @@ -6,7 +6,7 @@ FactoryBot.define do type { 'Service' } end - factory :custom_issue_tracker_service, class: CustomIssueTrackerService do + factory :custom_issue_tracker_service, class: 'CustomIssueTrackerService' do project active { true } issue_tracker diff --git a/spec/factories/terms.rb b/spec/factories/terms.rb index b98a2453f7e737675eb09cb976c8556e4ff8b10b..915a6099c2aa83ca5140492f9823708fd7b63974 100644 --- a/spec/factories/terms.rb +++ b/spec/factories/terms.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true FactoryBot.define do - factory :term, class: ApplicationSetting::Term do + factory :term, class: 'ApplicationSetting::Term' do terms { "Lorem ipsum dolor sit amet, consectetur adipiscing elit." } end end diff --git a/spec/factories/todos.rb b/spec/factories/todos.rb index bb91fc9ac8e2f5103d61a98cf9340a8f6905bd38..0b5d00cff675b4bbf19fd1fe46782d6a3bc54754 100644 --- a/spec/factories/todos.rb +++ b/spec/factories/todos.rb @@ -46,7 +46,7 @@ FactoryBot.define do end end - factory :on_commit_todo, class: Todo do + factory :on_commit_todo, class: 'Todo' do project author user diff --git a/spec/features/admin/admin_broadcast_messages_spec.rb b/spec/features/admin/admin_broadcast_messages_spec.rb index c5a302ce78b452dcd022ca29e39d0fa23fea11c0..bf7f8563e6847c3e8420346d9f734baad1c0f56d 100644 --- a/spec/features/admin/admin_broadcast_messages_spec.rb +++ b/spec/features/admin/admin_broadcast_messages_spec.rb @@ -13,7 +13,7 @@ describe 'Admin Broadcast Messages' do expect(page).to have_content 'Migration to new server' end - it 'Create a customized broadcast message' do + it 'creates a customized broadcast banner message' do fill_in 'broadcast_message_message', with: 'Application update from **4:00 CST to 5:00 CST**' fill_in 'broadcast_message_color', with: '#f2dede' fill_in 'broadcast_message_target_path', with: '*/user_onboarded' @@ -28,6 +28,20 @@ describe 'Admin Broadcast Messages' do expect(page).to have_selector %(div[style="background-color: #f2dede; color: #b94a48"]) end + it 'creates a customized broadcast notification message' do + fill_in 'broadcast_message_message', with: 'Application update from **4:00 CST to 5:00 CST**' + fill_in 'broadcast_message_target_path', with: '*/user_onboarded' + select 'Notification', from: 'broadcast_message_broadcast_type' + select Date.today.next_year.year, from: 'broadcast_message_ends_at_1i' + click_button 'Add broadcast message' + + expect(current_path).to eq admin_broadcast_messages_path + expect(page).to have_content 'Application update from 4:00 CST to 5:00 CST' + expect(page).to have_content '*/user_onboarded' + expect(page).to have_content 'Notification' + expect(page).to have_selector 'strong', text: '4:00 CST to 5:00 CST' + end + it 'Edit an existing broadcast message' do click_link 'Edit' fill_in 'broadcast_message_message', with: 'Application update RIGHT NOW' @@ -44,10 +58,20 @@ describe 'Admin Broadcast Messages' do expect(page).not_to have_content 'Migration to new server' end - it 'Live preview a customized broadcast message', :js do + it 'updates a preview of a customized broadcast banner message', :js do + fill_in 'broadcast_message_message', with: "Live **Markdown** previews. :tada:" + + page.within('.js-broadcast-banner-message-preview') do + expect(page).to have_selector('strong', text: 'Markdown') + expect(page).to have_emoji('tada') + end + end + + it 'updates a preview of a customized broadcast notification message', :js do fill_in 'broadcast_message_message', with: "Live **Markdown** previews. :tada:" + select 'Notification', from: 'broadcast_message_broadcast_type' - page.within('.broadcast-message-preview') do + page.within('.js-broadcast-notification-message-preview') do expect(page).to have_selector('strong', text: 'Markdown') expect(page).to have_emoji('tada') end diff --git a/spec/features/admin/admin_groups_spec.rb b/spec/features/admin/admin_groups_spec.rb index 257e5cb8bf0aaa91a63fa8847cbad692d64ae99c..9a4889a0335eea3917c39a776542d8ef046476d3 100644 --- a/spec/features/admin/admin_groups_spec.rb +++ b/spec/features/admin/admin_groups_spec.rb @@ -167,14 +167,14 @@ describe 'Admin Groups' do it 'adds admin a to a group as developer', :js do visit group_group_members_path(group) - page.within '.users-group-form' do + page.within '.invite-users-form' do select2(current_user.id, from: '#user_ids', multiple: true) select 'Developer', from: 'access_level' end - click_button 'Add to group' + click_button 'Invite' - page.within '.content-list' do + page.within '[data-qa-selector="members_list"]' do expect(page).to have_content(current_user.name) expect(page).to have_content('Developer') end @@ -187,7 +187,7 @@ describe 'Admin Groups' do visit group_group_members_path(group) - page.within '.content-list' do + page.within '[data-qa-selector="members_list"]' do expect(page).to have_content(current_user.name) expect(page).to have_content('Developer') end @@ -196,7 +196,7 @@ describe 'Admin Groups' do visit group_group_members_path(group) - page.within '.content-list' do + page.within '[data-qa-selector="members_list"]' do expect(page).not_to have_content(current_user.name) expect(page).not_to have_content('Developer') end diff --git a/spec/features/admin/admin_hooks_spec.rb b/spec/features/admin/admin_hooks_spec.rb index b4bcbe9d812e037f0eba8579fe14135f1afc4e9b..64326f3be32197fa12163aaa1a53d3a34a23060e 100644 --- a/spec/features/admin/admin_hooks_spec.rb +++ b/spec/features/admin/admin_hooks_spec.rb @@ -28,11 +28,11 @@ describe 'Admin::Hooks' do end it 'renders plugins list as well' do - allow(Gitlab::Plugin).to receive(:files).and_return(['foo.rb', 'bar.clj']) + allow(Gitlab::FileHook).to receive(:files).and_return(['foo.rb', 'bar.clj']) visit admin_hooks_path - expect(page).to have_content('Plugins') + expect(page).to have_content('File Hooks') expect(page).to have_content('foo.rb') expect(page).to have_content('bar.clj') end diff --git a/spec/features/admin/admin_projects_spec.rb b/spec/features/admin/admin_projects_spec.rb index 7c40ac5bde364f972208f1937a2d55ab1f8f5e84..d1889d3a89acd6ac418134b19079ba6aa226f002 100644 --- a/spec/features/admin/admin_projects_spec.rb +++ b/spec/features/admin/admin_projects_spec.rb @@ -98,12 +98,12 @@ describe "Admin::Projects" do it 'adds admin a to a project as developer', :js do visit project_project_members_path(project) - page.within '.users-project-form' do + page.within '.invite-users-form' do select2(current_user.id, from: '#user_ids', multiple: true) select 'Developer', from: 'access_level' end - click_button 'Add to project' + click_button 'Invite' page.within '.content-list' do expect(page).to have_content(current_user.name) diff --git a/spec/features/admin/admin_runners_spec.rb b/spec/features/admin/admin_runners_spec.rb index 0d5f5df71b65b194c887b2f5b38f1ea5036c03a1..6bcadda6523fc359c907e61ba518790d31375f9d 100644 --- a/spec/features/admin/admin_runners_spec.rb +++ b/spec/features/admin/admin_runners_spec.rb @@ -57,7 +57,7 @@ describe "Admin Runners" do expect(page).to have_content 'runner-active' expect(page).to have_content 'runner-paused' - input_filtered_search_keys('status:active') + input_filtered_search_keys('status=active') expect(page).to have_content 'runner-active' expect(page).not_to have_content 'runner-paused' end @@ -68,7 +68,7 @@ describe "Admin Runners" do visit admin_runners_path - input_filtered_search_keys('status:offline') + input_filtered_search_keys('status=offline') expect(page).not_to have_content 'runner-active' expect(page).not_to have_content 'runner-paused' @@ -83,12 +83,12 @@ describe "Admin Runners" do visit admin_runners_path - input_filtered_search_keys('status:active') + input_filtered_search_keys('status=active') expect(page).to have_content 'runner-a-1' expect(page).to have_content 'runner-b-1' expect(page).not_to have_content 'runner-a-2' - input_filtered_search_keys('status:active runner-a') + input_filtered_search_keys('status=active runner-a') expect(page).to have_content 'runner-a-1' expect(page).not_to have_content 'runner-b-1' expect(page).not_to have_content 'runner-a-2' @@ -105,7 +105,7 @@ describe "Admin Runners" do expect(page).to have_content 'runner-project' expect(page).to have_content 'runner-group' - input_filtered_search_keys('type:project_type') + input_filtered_search_keys('type=project_type') expect(page).to have_content 'runner-project' expect(page).not_to have_content 'runner-group' end @@ -116,7 +116,7 @@ describe "Admin Runners" do visit admin_runners_path - input_filtered_search_keys('type:instance_type') + input_filtered_search_keys('type=instance_type') expect(page).not_to have_content 'runner-project' expect(page).not_to have_content 'runner-group' @@ -131,12 +131,12 @@ describe "Admin Runners" do visit admin_runners_path - input_filtered_search_keys('type:project_type') + input_filtered_search_keys('type=project_type') expect(page).to have_content 'runner-a-1' expect(page).to have_content 'runner-b-1' expect(page).not_to have_content 'runner-a-2' - input_filtered_search_keys('type:project_type runner-a') + input_filtered_search_keys('type=project_type runner-a') expect(page).to have_content 'runner-a-1' expect(page).not_to have_content 'runner-b-1' expect(page).not_to have_content 'runner-a-2' @@ -153,7 +153,7 @@ describe "Admin Runners" do expect(page).to have_content 'runner-blue' expect(page).to have_content 'runner-red' - input_filtered_search_keys('tag:blue') + input_filtered_search_keys('tag=blue') expect(page).to have_content 'runner-blue' expect(page).not_to have_content 'runner-red' @@ -165,7 +165,7 @@ describe "Admin Runners" do visit admin_runners_path - input_filtered_search_keys('tag:red') + input_filtered_search_keys('tag=red') expect(page).not_to have_content 'runner-blue' expect(page).not_to have_content 'runner-blue' @@ -179,13 +179,13 @@ describe "Admin Runners" do visit admin_runners_path - input_filtered_search_keys('tag:blue') + input_filtered_search_keys('tag=blue') expect(page).to have_content 'runner-a-1' expect(page).to have_content 'runner-b-1' expect(page).not_to have_content 'runner-a-2' - input_filtered_search_keys('tag:blue runner-a') + input_filtered_search_keys('tag=blue runner-a') expect(page).to have_content 'runner-a-1' expect(page).not_to have_content 'runner-b-1' diff --git a/spec/features/boards/boards_spec.rb b/spec/features/boards/boards_spec.rb index c740e4e26d91f2b38ab87c9a99f2ba7507c3adea..8aad598b843228de36ca9b8b70b3a5cec394f3fc 100644 --- a/spec/features/boards/boards_spec.rb +++ b/spec/features/boards/boards_spec.rb @@ -163,9 +163,7 @@ describe 'Issue Boards', :js do end it 'infinite scrolls list' do - 50.times do - create(:labeled_issue, project: project, labels: [planning]) - end + create_list(:labeled_issue, 50, project: project, labels: [planning]) visit project_board_path(project, board) wait_for_requests @@ -475,9 +473,7 @@ describe 'Issue Boards', :js do end it 'infinite scrolls list with label filter' do - 50.times do - create(:labeled_issue, project: project, labels: [planning, testing]) - end + create_list(:labeled_issue, 50, project: project, labels: [planning, testing]) set_filter("label", testing.title) click_filter_link(testing.title) @@ -628,7 +624,7 @@ describe 'Issue Boards', :js do end def set_filter(type, text) - find('.filtered-search').native.send_keys("#{type}:#{text}") + find('.filtered-search').native.send_keys("#{type}=#{text}") end def submit_filter diff --git a/spec/features/boards/modal_filter_spec.rb b/spec/features/boards/modal_filter_spec.rb index 70bc067f79dc4de5eb0faa665d94ca1fb06bae82..d14041ecf3fe075b01e48312450839f7e1903b9a 100644 --- a/spec/features/boards/modal_filter_spec.rb +++ b/spec/features/boards/modal_filter_spec.rb @@ -211,7 +211,7 @@ describe 'Issue Boards add issue modal filtering', :js do end def set_filter(type, text = '') - find('.add-issues-modal .filtered-search').native.send_keys("#{type}:#{text}") + find('.add-issues-modal .filtered-search').native.send_keys("#{type}=#{text}") end def submit_filter diff --git a/spec/features/boards/sidebar_spec.rb b/spec/features/boards/sidebar_spec.rb index 9143db16b87be220723271952b6ba0a0a64d3bd8..c7edb574f19a07e01e6c8fd0f1219ca7c250e9dd 100644 --- a/spec/features/boards/sidebar_spec.rb +++ b/spec/features/boards/sidebar_spec.rb @@ -318,7 +318,9 @@ describe 'Issue Boards', :js do wait_for_requests click_link bug.title - within('.dropdown-menu-labels') { expect(page).to have_selector('.is-active', count: 3) } + + wait_for_requests + click_link regression.title wait_for_requests diff --git a/spec/features/clusters/installing_applications_shared_examples.rb b/spec/features/clusters/installing_applications_shared_examples.rb index 988cd228c1cc23e1066b302d72480c3270ccd132..20648ed3d46afc8d95e1ad29f73e78629367bc13 100644 --- a/spec/features/clusters/installing_applications_shared_examples.rb +++ b/spec/features/clusters/installing_applications_shared_examples.rb @@ -181,11 +181,8 @@ shared_examples "installing applications on a cluster" do context 'when user installs Elastic Stack' do before do allow(ClusterInstallAppWorker).to receive(:perform_async) - allow(ClusterWaitForIngressIpAddressWorker).to receive(:perform_in) - allow(ClusterWaitForIngressIpAddressWorker).to receive(:perform_async) create(:clusters_applications_helm, :installed, cluster: cluster) - create(:clusters_applications_ingress, :installed, external_ip: '127.0.0.1', cluster: cluster) page.within('.js-cluster-application-row-elastic_stack') do click_button 'Install' diff --git a/spec/features/cycle_analytics_spec.rb b/spec/features/cycle_analytics_spec.rb index e9751aa2e7249fa44a511e6b152c7cc0f03627ab..0cafdb4e9824e91f891524bc42acf5458495e6f9 100644 --- a/spec/features/cycle_analytics_spec.rb +++ b/spec/features/cycle_analytics_spec.rb @@ -76,7 +76,7 @@ describe 'Cycle Analytics', :js do click_stage('Staging') expect_build_to_be_present - click_stage('Production') + click_stage('Total') expect_issue_to_be_present end diff --git a/spec/features/dashboard/instance_statistics_spec.rb b/spec/features/dashboard/instance_statistics_spec.rb index 21ee2796bd87b6888541cd3fdb1c7abded0da74a..feb568d8ef4b8dd532dad05403e55fa58cd16efa 100644 --- a/spec/features/dashboard/instance_statistics_spec.rb +++ b/spec/features/dashboard/instance_statistics_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -describe 'Showing instance statistics' do +describe 'Showing analytics' do before do sign_in user if user end @@ -13,10 +13,10 @@ describe 'Showing instance statistics' do context 'for unauthenticated users' do let(:user) { nil } - it 'does not show the instance statistics link' do + it 'does not show the Analytics link' do subject - expect(page).not_to have_link('Instance Statistics') + expect(page).not_to have_link('Analytics') end end @@ -28,10 +28,10 @@ describe 'Showing instance statistics' do stub_application_setting(instance_statistics_visibility_private: false) end - it 'shows the instance statistics link' do + it 'shows the analytics link' do subject - expect(page).to have_link('Instance Statistics') + expect(page).to have_link('Analytics') end end @@ -40,10 +40,14 @@ describe 'Showing instance statistics' do stub_application_setting(instance_statistics_visibility_private: true) end - it 'shows the instance statistics link' do + it 'does not show the analytics link' do subject - expect(page).not_to have_link('Instance Statistics') + # Skipping this test on EE as there is an EE specifc spec for this functionality + # ee/spec/features/dashboards/analytics_spec.rb + skip if Gitlab.ee? + + expect(page).not_to have_link('Analytics') end end end @@ -51,10 +55,10 @@ describe 'Showing instance statistics' do context 'for admins' do let(:user) { create(:admin) } - it 'shows the instance statistics link' do + it 'shows the analytics link' do subject - expect(page).to have_link('Instance Statistics') + expect(page).to have_link('Analytics') end end end diff --git a/spec/features/dashboard/issues_filter_spec.rb b/spec/features/dashboard/issues_filter_spec.rb index 1352e1bd8fcbc94e104f8ecfa94ec3c021eb3e47..8e7fd1f500fc536fa1d3f19e75088dc4a1b22c47 100644 --- a/spec/features/dashboard/issues_filter_spec.rb +++ b/spec/features/dashboard/issues_filter_spec.rb @@ -28,14 +28,14 @@ describe 'Dashboard Issues filtering', :js do context 'filtering by milestone' do it 'shows all issues with no milestone' do - input_filtered_search("milestone:none") + input_filtered_search("milestone=none") expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1) expect(page).to have_selector('.issue', count: 1) end it 'shows all issues with the selected milestone' do - input_filtered_search("milestone:%\"#{milestone.title}\"") + input_filtered_search("milestone=%\"#{milestone.title}\"") expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1) expect(page).to have_selector('.issue', count: 1) @@ -63,7 +63,7 @@ describe 'Dashboard Issues filtering', :js do let!(:label_link) { create(:label_link, label: label, target: issue) } it 'shows all issues with the selected label' do - input_filtered_search("label:~#{label.title}") + input_filtered_search("label=~#{label.title}") page.within 'ul.content-list' do expect(page).to have_content issue.title diff --git a/spec/features/dashboard/issues_spec.rb b/spec/features/dashboard/issues_spec.rb index cb055ff841688623e3e55967436679a8bf3e6962..a2ead1b5d334fb98114485abd0020be5b2a36e6d 100644 --- a/spec/features/dashboard/issues_spec.rb +++ b/spec/features/dashboard/issues_spec.rb @@ -30,7 +30,7 @@ RSpec.describe 'Dashboard Issues' do it 'shows issues when current user is author', :js do reset_filters - input_filtered_search("author:#{current_user.to_reference}") + input_filtered_search("author=#{current_user.to_reference}") expect(page).to have_content(authored_issue.title) expect(page).to have_content(authored_issue_on_public_project.title) diff --git a/spec/features/dashboard/merge_requests_spec.rb b/spec/features/dashboard/merge_requests_spec.rb index 0c1e1d5910b2f52305cc61784bddadf9fb0b6f9f..bb515cfae826b80d08ffd67e1d9eace42d766b70 100644 --- a/spec/features/dashboard/merge_requests_spec.rb +++ b/spec/features/dashboard/merge_requests_spec.rb @@ -107,7 +107,7 @@ describe 'Dashboard Merge Requests' do it 'shows authored merge requests', :js do reset_filters - input_filtered_search("author:#{current_user.to_reference}") + input_filtered_search("author=#{current_user.to_reference}") expect(page).to have_content(authored_merge_request.title) expect(page).to have_content(authored_merge_request_from_fork.title) @@ -120,7 +120,7 @@ describe 'Dashboard Merge Requests' do it 'shows labeled merge requests', :js do reset_filters - input_filtered_search("label:#{label.name}") + input_filtered_search("label=#{label.name}") expect(page).to have_content(labeled_merge_request.title) diff --git a/spec/features/dashboard/projects_spec.rb b/spec/features/dashboard/projects_spec.rb index f10cdf6da1e77210efc0d8d7fbd5501026b2c581..73f759f8a5461c41426751f1a1a44059410d5212 100644 --- a/spec/features/dashboard/projects_spec.rb +++ b/spec/features/dashboard/projects_spec.rb @@ -173,6 +173,19 @@ describe 'Dashboard Projects' do end end + shared_examples 'hidden pipeline status' do + it 'does not show the pipeline status' do + visit dashboard_projects_path + + page.within('.controls') do + expect(page).not_to have_xpath("//a[@href='#{pipelines_project_commit_path(project, project.commit, ref: pipeline.ref)}']") + expect(page).not_to have_css('.ci-status-link') + expect(page).not_to have_css('.ci-status-icon-success') + expect(page).not_to have_link('Pipeline: passed') + end + end + end + context 'guest user of project and project has private pipelines' do let(:guest_user) { create(:user) } @@ -182,16 +195,15 @@ describe 'Dashboard Projects' do sign_in(guest_user) end - it 'shows that the last pipeline passed' do - visit dashboard_projects_path + it_behaves_like 'hidden pipeline status' + end - page.within('.controls') do - expect(page).not_to have_xpath("//a[@href='#{pipelines_project_commit_path(project, project.commit, ref: pipeline.ref)}']") - expect(page).not_to have_css('.ci-status-link') - expect(page).not_to have_css('.ci-status-icon-success') - expect(page).not_to have_link('Pipeline: passed') - end + context 'when dashboard_pipeline_status is disabled' do + before do + stub_feature_flags(dashboard_pipeline_status: false) end + + it_behaves_like 'hidden pipeline status' end end diff --git a/spec/features/dashboard/snippets_spec.rb b/spec/features/dashboard/snippets_spec.rb index ff3eb58931de39679300b574e4c07d8e42299974..94dc8601abb5b450c936c20602019f674b226338 100644 --- a/spec/features/dashboard/snippets_spec.rb +++ b/spec/features/dashboard/snippets_spec.rb @@ -91,6 +91,7 @@ describe 'Dashboard snippets' do context 'as an external user' do let(:user) { create(:user, :external) } + before do sign_in(user) visit dashboard_snippets_path diff --git a/spec/features/groups/issues_spec.rb b/spec/features/groups/issues_spec.rb index b9b233026fd2a89cd26e33b0889911920ad50b55..a3fa87e3242d0b033f956e4c650e70de151676d4 100644 --- a/spec/features/groups/issues_spec.rb +++ b/spec/features/groups/issues_spec.rb @@ -48,7 +48,7 @@ describe 'Group issues page' do let(:user2) { user_outside_group } it 'filters by only group users' do - filtered_search.set('assignee:') + filtered_search.set('assignee=') expect(find('#js-dropdown-assignee .filter-dropdown')).to have_content(user.name) expect(find('#js-dropdown-assignee .filter-dropdown')).not_to have_content(user2.name) diff --git a/spec/features/groups/members/manage_groups_spec.rb b/spec/features/groups/members/manage_groups_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..55f9418521f60a849a5de80b3c6ce480a744580b --- /dev/null +++ b/spec/features/groups/members/manage_groups_spec.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'Groups > Members > Manage groups', :js do + include Select2Helper + include Spec::Support::Helpers::Features::ListRowsHelpers + + let(:user) { create(:user) } + let(:shared_with_group) { create(:group) } + let(:shared_group) { create(:group) } + + before do + shared_group.add_owner(user) + sign_in(user) + end + + context 'with share groups with groups feature flag' do + before do + stub_feature_flags(shared_with_group: true) + end + + it 'add group to group' do + visit group_group_members_path(shared_group) + + add_group(shared_with_group.id, 'Reporter') + + page.within(first_row) do + expect(page).to have_content(shared_with_group.name) + expect(page).to have_content('Reporter') + end + end + + it 'remove user from group' do + create(:group_group_link, shared_group: shared_group, + shared_with_group: shared_with_group, group_access: ::Gitlab::Access::DEVELOPER) + + visit group_group_members_path(shared_group) + + expect(page).to have_content(shared_with_group.name) + + accept_confirm do + find(:css, '#existing_shares li', text: shared_with_group.name).find(:css, 'a.btn-remove').click + end + + wait_for_requests + + expect(page).not_to have_content(shared_with_group.name) + end + + it 'update group to owner level' do + create(:group_group_link, shared_group: shared_group, + shared_with_group: shared_with_group, group_access: ::Gitlab::Access::DEVELOPER) + + visit group_group_members_path(shared_group) + + page.within(first_row) do + click_button('Developer') + click_link('Maintainer') + + wait_for_requests + + expect(page).to have_button('Maintainer') + end + end + + def add_group(id, role) + page.click_link 'Invite group' + page.within ".invite-group-form" do + select2(id, from: "#shared_with_group_id") + select(role, from: "shared_group_access") + click_button "Invite" + end + end + end + + context 'without share groups with groups feature flag' do + before do + stub_feature_flags(share_group_with_group: false) + end + + it 'does not render invitation form and tabs' do + visit group_group_members_path(shared_group) + + expect(page).not_to have_link('Invite member') + expect(page).not_to have_link('Invite group') + end + end +end diff --git a/spec/features/groups/members/manage_members_spec.rb b/spec/features/groups/members/manage_members_spec.rb index cdd16ae9441ba08e0f1cad59330ed6e848734dbd..e4ba3022d8b22046e4ba70c9187b8c9209008df5 100644 --- a/spec/features/groups/members/manage_members_spec.rb +++ b/spec/features/groups/members/manage_members_spec.rb @@ -113,7 +113,8 @@ describe 'Groups > Members > Manage members' do visit group_group_members_path(group) - expect(page).not_to have_button 'Add to group' + expect(page).not_to have_selector '.invite-users-form' + expect(page).not_to have_selector '.invite-group-form' page.within(second_row) do # Can not modify user2 role @@ -125,11 +126,10 @@ describe 'Groups > Members > Manage members' do end def add_user(id, role) - page.within ".users-group-form" do + page.within ".invite-users-form" do select2(id, from: "#user_ids", multiple: true) select(role, from: "access_level") + click_button "Invite" end - - click_button "Add to group" end end diff --git a/spec/features/groups/members/search_members_spec.rb b/spec/features/groups/members/search_members_spec.rb index 9c17aac09e81d1471bec1f969bae583b550b1d50..fda129ce422e1c295b1de08c83fab3f6605e2127 100644 --- a/spec/features/groups/members/search_members_spec.rb +++ b/spec/features/groups/members/search_members_spec.rb @@ -24,7 +24,7 @@ describe 'Search group member' do find('.user-search-btn').click end - group_members_list = find(".card .content-list") + group_members_list = find('[data-qa-selector="members_list"]') expect(group_members_list).to have_content(member.name) expect(group_members_list).not_to have_content(user.name) end diff --git a/spec/features/groups/merge_requests_spec.rb b/spec/features/groups/merge_requests_spec.rb index 59230d6891ab00910753ec47e3ac56de5493206c..0038a8e4892071a7740d1213acd4ddb3691ce923 100644 --- a/spec/features/groups/merge_requests_spec.rb +++ b/spec/features/groups/merge_requests_spec.rb @@ -52,7 +52,7 @@ describe 'Group merge requests page' do let(:user2) { user_outside_group } it 'filters by assignee only group users' do - filtered_search.set('assignee:') + filtered_search.set('assignee=') expect(find('#js-dropdown-assignee .filter-dropdown')).to have_content(user.name) expect(find('#js-dropdown-assignee .filter-dropdown')).not_to have_content(user2.name) diff --git a/spec/features/import/manifest_import_spec.rb b/spec/features/import/manifest_import_spec.rb index 89bf69dea7d0d156b632bd58e599a8d31e6ec0f7..36478128dd1c93d032b6055747edbc97d3307244 100644 --- a/spec/features/import/manifest_import_spec.rb +++ b/spec/features/import/manifest_import_spec.rb @@ -24,17 +24,17 @@ describe 'Import multiple repositories by uploading a manifest file', :js do expect(page).to have_content('https://android-review.googlesource.com/platform/build/blueprint') end - it 'imports successfully imports a project', :sidekiq_might_not_need_inline do + it 'imports successfully imports a project', :sidekiq_inline do visit new_import_manifest_path attach_file('manifest', Rails.root.join('spec/fixtures/aosp_manifest.xml')) click_on 'List available repositories' - page.within(first_row) do + page.within(second_row) do click_on 'Import' expect(page).to have_content 'Done' - expect(page).to have_content("#{group.full_path}/build/make") + expect(page).to have_content("#{group.full_path}/build/blueprint") end end @@ -47,7 +47,7 @@ describe 'Import multiple repositories by uploading a manifest file', :js do expect(page).to have_content 'The uploaded file is not a valid XML file.' end - def first_row - page.all('table.import-jobs tbody tr')[0] + def second_row + page.all('table.import-jobs tbody tr')[1] end end diff --git a/spec/features/instance_statistics/cohorts_spec.rb b/spec/features/instance_statistics/cohorts_spec.rb index 3940e8fa389ce56b103d7e0f7ec7a474c1d92de9..0bb2e4b997d9a1a16a2b8dc45922d894f75bb63b 100644 --- a/spec/features/instance_statistics/cohorts_spec.rb +++ b/spec/features/instance_statistics/cohorts_spec.rb @@ -10,7 +10,7 @@ describe 'Cohorts page' do end it 'See users count per month' do - 2.times { create(:user) } + create_list(:user, 2) visit instance_statistics_cohorts_path diff --git a/spec/features/issuables/issuable_list_spec.rb b/spec/features/issuables/issuable_list_spec.rb index 30c516459c5e8834a7dc2b46de1b9d22070ff129..bcc05d313ad595cdadb81c7d71f9d0abb06a13e2 100644 --- a/spec/features/issuables/issuable_list_spec.rb +++ b/spec/features/issuables/issuable_list_spec.rb @@ -83,9 +83,7 @@ describe 'issuable list' do create(:merge_request, title: FFaker::Lorem.sentence, source_project: project, source_branch: source_branch, head_pipeline: pipeline) end - 2.times do - create(:note_on_issue, noteable: issuable, project: project) - end + create_list(:note_on_issue, 2, noteable: issuable, project: project) create(:award_emoji, :downvote, awardable: issuable) create(:award_emoji, :upvote, awardable: issuable) diff --git a/spec/features/issues/filtered_search/dropdown_assignee_spec.rb b/spec/features/issues/filtered_search/dropdown_assignee_spec.rb index e1177bedd2d04026660908fccc40feabddfaaf4f..8aa29cddd5f097e0c71fa90fbedc5431471017e9 100644 --- a/spec/features/issues/filtered_search/dropdown_assignee_spec.rb +++ b/spec/features/issues/filtered_search/dropdown_assignee_spec.rb @@ -7,24 +7,11 @@ describe 'Dropdown assignee', :js do let!(:project) { create(:project) } let!(:user) { create(:user, name: 'administrator', username: 'root') } - let!(:user_john) { create(:user, name: 'John', username: 'th0mas') } - let!(:user_jacob) { create(:user, name: 'Jacob', username: 'otter32') } - let(:filtered_search) { find('.filtered-search') } let(:js_dropdown_assignee) { '#js-dropdown-assignee' } let(:filter_dropdown) { find("#{js_dropdown_assignee} .filter-dropdown") } - def dropdown_assignee_size - filter_dropdown.all('.filter-dropdown-item').size - end - - def click_assignee(text) - find('#js-dropdown-assignee .filter-dropdown .filter-dropdown-item', text: text).click - end - before do project.add_maintainer(user) - project.add_maintainer(user_john) - project.add_maintainer(user_jacob) sign_in(user) create(:issue, project: project) @@ -32,153 +19,23 @@ describe 'Dropdown assignee', :js do end describe 'behavior' do - it 'opens when the search bar has assignee:' do - input_filtered_search('assignee:', submit: false, extra_space: false) - - expect(page).to have_css(js_dropdown_assignee, visible: true) - end - - it 'closes when the search bar is unfocused' do - find('body').click - - expect(page).to have_css(js_dropdown_assignee, visible: false) - end - - it 'shows loading indicator when opened' do - slow_requests do - # We aren't using `input_filtered_search` because we want to see the loading indicator - filtered_search.set('assignee:') - - expect(page).to have_css('#js-dropdown-assignee .filter-dropdown-loading', visible: true) - end - end - - it 'hides loading indicator when loaded' do - input_filtered_search('assignee:', submit: false, extra_space: false) - - expect(find(js_dropdown_assignee)).not_to have_css('.filter-dropdown-loading') - end - it 'loads all the assignees when opened' do - input_filtered_search('assignee:', submit: false, extra_space: false) + input_filtered_search('assignee=', submit: false, extra_space: false) - expect(dropdown_assignee_size).to eq(4) + expect_filtered_search_dropdown_results(filter_dropdown, 2) end it 'shows current user at top of dropdown' do - input_filtered_search('assignee:', submit: false, extra_space: false) + input_filtered_search('assignee=', submit: false, extra_space: false) expect(filter_dropdown.first('.filter-dropdown-item')).to have_content(user.name) end end - describe 'filtering' do - before do - input_filtered_search('assignee:', submit: false, extra_space: false) - - expect(find("#{js_dropdown_assignee} .filter-dropdown")).to have_content(user_john.name) - expect(find("#{js_dropdown_assignee} .filter-dropdown")).to have_content(user_jacob.name) - expect(find("#{js_dropdown_assignee} .filter-dropdown")).to have_content(user.name) - end - - it 'filters by name' do - input_filtered_search('jac', submit: false, extra_space: false) - - expect(find("#{js_dropdown_assignee} .filter-dropdown")).to have_content(user_jacob.name) - expect(find("#{js_dropdown_assignee} .filter-dropdown")).to have_no_content(user.name) - end - - it 'filters by case insensitive name' do - input_filtered_search('JAC', submit: false, extra_space: false) - - expect(find("#{js_dropdown_assignee} .filter-dropdown")).to have_content(user_jacob.name) - expect(find("#{js_dropdown_assignee} .filter-dropdown")).to have_no_content(user.name) - end - - it 'filters by username with symbol' do - input_filtered_search('@ott', submit: false, extra_space: false) - - expect(find("#{js_dropdown_assignee} .filter-dropdown")).to have_content(user_jacob.name) - expect(find("#{js_dropdown_assignee} .filter-dropdown")).to have_content(user.name) - expect(find("#{js_dropdown_assignee} .filter-dropdown")).to have_no_content(user_john.name) - end - - it 'filters by case insensitive username with symbol' do - input_filtered_search('@OTT', submit: false, extra_space: false) - - expect(find("#{js_dropdown_assignee} .filter-dropdown")).to have_content(user_jacob.name) - expect(find("#{js_dropdown_assignee} .filter-dropdown")).to have_content(user.name) - expect(find("#{js_dropdown_assignee} .filter-dropdown")).to have_no_content(user_john.name) - end - - it 'filters by username without symbol' do - input_filtered_search('ott', submit: false, extra_space: false) - - wait_for_requests - - expect(find("#{js_dropdown_assignee} .filter-dropdown")).to have_content(user_jacob.name) - expect(find("#{js_dropdown_assignee} .filter-dropdown")).to have_content(user.name) - expect(find("#{js_dropdown_assignee} .filter-dropdown")).to have_no_content(user_john.name) - end - - it 'filters by case insensitive username without symbol' do - input_filtered_search('OTT', submit: false, extra_space: false) - - wait_for_requests - - expect(find("#{js_dropdown_assignee} .filter-dropdown")).to have_content(user_jacob.name) - expect(find("#{js_dropdown_assignee} .filter-dropdown")).to have_content(user.name) - expect(find("#{js_dropdown_assignee} .filter-dropdown")).to have_no_content(user_john.name) - end - end - - describe 'selecting from dropdown' do - before do - input_filtered_search('assignee:', submit: false, extra_space: false) - end - - it 'fills in the assignee username when the assignee has not been filtered' do - click_assignee(user_jacob.name) - - wait_for_requests - - expect(page).to have_css(js_dropdown_assignee, visible: false) - expect_tokens([assignee_token(user_jacob.name)]) - expect_filtered_search_input_empty - end - - it 'fills in the assignee username when the assignee has been filtered' do - input_filtered_search('roo', submit: false, extra_space: false) - click_assignee(user.name) - - wait_for_requests - - expect(page).to have_css(js_dropdown_assignee, visible: false) - expect_tokens([assignee_token(user.name)]) - expect_filtered_search_input_empty - end - - it 'selects `None`' do - find('#js-dropdown-assignee .filter-dropdown-item', text: 'None').click - - expect(page).to have_css(js_dropdown_assignee, visible: false) - expect_tokens([assignee_token('None')]) - expect_filtered_search_input_empty - end - - it 'selects `Any`' do - find('#js-dropdown-assignee .filter-dropdown-item', text: 'Any').click - - expect(page).to have_css(js_dropdown_assignee, visible: false) - expect_tokens([assignee_token('Any')]) - expect_filtered_search_input_empty - end - end - describe 'selecting from dropdown without Ajax call' do before do Gitlab::Testing::RequestBlockerMiddleware.block_requests! - input_filtered_search('assignee:', submit: false, extra_space: false) + input_filtered_search('assignee=', submit: false, extra_space: false) end after do @@ -186,59 +43,11 @@ describe 'Dropdown assignee', :js do end it 'selects current user' do - find('#js-dropdown-assignee .filter-dropdown-item', text: user.username).click + find("#{js_dropdown_assignee} .filter-dropdown-item", text: user.username).click expect(page).to have_css(js_dropdown_assignee, visible: false) expect_tokens([assignee_token(user.username)]) expect_filtered_search_input_empty end end - - describe 'input has existing content' do - it 'opens assignee dropdown with existing search term' do - input_filtered_search('searchTerm assignee:', submit: false, extra_space: false) - - expect(page).to have_css(js_dropdown_assignee, visible: true) - end - - it 'opens assignee dropdown with existing author' do - input_filtered_search('author:@user assignee:', submit: false, extra_space: false) - - expect(page).to have_css(js_dropdown_assignee, visible: true) - end - - it 'opens assignee dropdown with existing label' do - input_filtered_search('label:~bug assignee:', submit: false, extra_space: false) - - expect(page).to have_css(js_dropdown_assignee, visible: true) - end - - it 'opens assignee dropdown with existing milestone' do - input_filtered_search('milestone:%v1.0 assignee:', submit: false, extra_space: false) - - expect(page).to have_css(js_dropdown_assignee, visible: true) - end - - it 'opens assignee dropdown with existing my-reaction' do - input_filtered_search('my-reaction:star assignee:', submit: false, extra_space: false) - - expect(page).to have_css(js_dropdown_assignee, visible: true) - end - end - - describe 'caching requests' do - it 'caches requests after the first load' do - input_filtered_search('assignee:', submit: false, extra_space: false) - initial_size = dropdown_assignee_size - - expect(initial_size).to be > 0 - - new_user = create(:user) - project.add_maintainer(new_user) - find('.filtered-search-box .clear-search').click - input_filtered_search('assignee:', submit: false, extra_space: false) - - expect(dropdown_assignee_size).to eq(initial_size) - end - end end diff --git a/spec/features/issues/filtered_search/dropdown_author_spec.rb b/spec/features/issues/filtered_search/dropdown_author_spec.rb index bd22eb1056bb7ef8a0b2b04c0ba6a52a6382d379..c95bd7071b3cc9cf4045e49e627a6761e00f4e4d 100644 --- a/spec/features/issues/filtered_search/dropdown_author_spec.rb +++ b/spec/features/issues/filtered_search/dropdown_author_spec.rb @@ -7,32 +7,11 @@ describe 'Dropdown author', :js do let!(:project) { create(:project) } let!(:user) { create(:user, name: 'administrator', username: 'root') } - let!(:user_john) { create(:user, name: 'John', username: 'th0mas') } - let!(:user_jacob) { create(:user, name: 'Jacob', username: 'ooter32') } - let(:filtered_search) { find('.filtered-search') } let(:js_dropdown_author) { '#js-dropdown-author' } - - def send_keys_to_filtered_search(input) - input.split("").each do |i| - filtered_search.send_keys(i) - end - - sleep 0.5 - wait_for_requests - end - - def dropdown_author_size - page.all('#js-dropdown-author .filter-dropdown .filter-dropdown-item').size - end - - def click_author(text) - find('#js-dropdown-author .filter-dropdown .filter-dropdown-item', text: text).click - end + let(:filter_dropdown) { find("#{js_dropdown_author} .filter-dropdown") } before do project.add_maintainer(user) - project.add_maintainer(user_john) - project.add_maintainer(user_jacob) sign_in(user) create(:issue, project: project) @@ -40,113 +19,23 @@ describe 'Dropdown author', :js do end describe 'behavior' do - it 'opens when the search bar has author:' do - filtered_search.set('author:') - - expect(page).to have_css(js_dropdown_author, visible: true) - end - - it 'closes when the search bar is unfocused' do - find('body').click - - expect(page).to have_css(js_dropdown_author, visible: false) - end - - it 'shows loading indicator when opened' do - slow_requests do - filtered_search.set('author:') - - expect(page).to have_css('#js-dropdown-author .filter-dropdown-loading', visible: true) - end - end - - it 'hides loading indicator when loaded' do - send_keys_to_filtered_search('author:') - - expect(page).not_to have_css('#js-dropdown-author .filter-dropdown-loading') - end - it 'loads all the authors when opened' do - send_keys_to_filtered_search('author:') + input_filtered_search('author=', submit: false, extra_space: false) - expect(dropdown_author_size).to eq(4) + expect_filtered_search_dropdown_results(filter_dropdown, 2) end it 'shows current user at top of dropdown' do - send_keys_to_filtered_search('author:') + input_filtered_search('author=', submit: false, extra_space: false) - expect(first('#js-dropdown-author li')).to have_content(user.name) - end - end - - describe 'filtering' do - before do - filtered_search.set('author') - send_keys_to_filtered_search(':') - end - - it 'filters by name' do - send_keys_to_filtered_search('jac') - - expect(dropdown_author_size).to eq(1) - end - - it 'filters by case insensitive name' do - send_keys_to_filtered_search('Jac') - - expect(dropdown_author_size).to eq(1) - end - - it 'filters by username with symbol' do - send_keys_to_filtered_search('@oot') - - expect(dropdown_author_size).to eq(2) - end - - it 'filters by username without symbol' do - send_keys_to_filtered_search('oot') - - expect(dropdown_author_size).to eq(2) - end - - it 'filters by case insensitive username without symbol' do - send_keys_to_filtered_search('OOT') - - expect(dropdown_author_size).to eq(2) - end - end - - describe 'selecting from dropdown' do - before do - filtered_search.set('author') - send_keys_to_filtered_search(':') - end - - it 'fills in the author username when the author has not been filtered' do - click_author(user_jacob.name) - - wait_for_requests - - expect(page).to have_css(js_dropdown_author, visible: false) - expect_tokens([author_token(user_jacob.name)]) - expect_filtered_search_input_empty - end - - it 'fills in the author username when the author has been filtered' do - click_author(user.name) - - wait_for_requests - - expect(page).to have_css(js_dropdown_author, visible: false) - expect_tokens([author_token(user.name)]) - expect_filtered_search_input_empty + expect(filter_dropdown.first('.filter-dropdown-item')).to have_content(user.name) end end describe 'selecting from dropdown without Ajax call' do before do Gitlab::Testing::RequestBlockerMiddleware.block_requests! - filtered_search.set('author:') + input_filtered_search('author=', submit: false, extra_space: false) end after do @@ -154,55 +43,11 @@ describe 'Dropdown author', :js do end it 'selects current user' do - find('#js-dropdown-author .filter-dropdown-item', text: user.username).click + find("#{js_dropdown_author} .filter-dropdown-item", text: user.username).click expect(page).to have_css(js_dropdown_author, visible: false) expect_tokens([author_token(user.username)]) expect_filtered_search_input_empty end end - - describe 'input has existing content' do - it 'opens author dropdown with existing search term' do - filtered_search.set('searchTerm author:') - - expect(page).to have_css(js_dropdown_author, visible: true) - end - - it 'opens author dropdown with existing assignee' do - filtered_search.set('assignee:@user author:') - - expect(page).to have_css(js_dropdown_author, visible: true) - end - - it 'opens author dropdown with existing label' do - filtered_search.set('label:~bug author:') - - expect(page).to have_css(js_dropdown_author, visible: true) - end - - it 'opens author dropdown with existing milestone' do - filtered_search.set('milestone:%v1.0 author:') - - expect(page).to have_css(js_dropdown_author, visible: true) - end - end - - describe 'caching requests' do - it 'caches requests after the first load' do - filtered_search.set('author') - send_keys_to_filtered_search(':') - initial_size = dropdown_author_size - - expect(initial_size).to be > 0 - - new_user = create(:user) - project.add_maintainer(new_user) - find('.filtered-search-box .clear-search').click - filtered_search.set('author') - send_keys_to_filtered_search(':') - - expect(dropdown_author_size).to eq(initial_size) - end - end end diff --git a/spec/features/issues/filtered_search/dropdown_base_spec.rb b/spec/features/issues/filtered_search/dropdown_base_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..2a800f054a0c8ad90335c687a68755b2f618af18 --- /dev/null +++ b/spec/features/issues/filtered_search/dropdown_base_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'Dropdown base', :js do + include FilteredSearchHelpers + + let!(:project) { create(:project) } + let!(:user) { create(:user, name: 'administrator', username: 'root') } + let(:filtered_search) { find('.filtered-search') } + let(:js_dropdown_assignee) { '#js-dropdown-assignee' } + let(:filter_dropdown) { find("#{js_dropdown_assignee} .filter-dropdown") } + + def dropdown_assignee_size + filter_dropdown.all('.filter-dropdown-item').size + end + + before do + project.add_maintainer(user) + sign_in(user) + create(:issue, project: project) + + visit project_issues_path(project) + end + + describe 'behavior' do + it 'shows loading indicator when opened' do + slow_requests do + # We aren't using `input_filtered_search` because we want to see the loading indicator + filtered_search.set('assignee=') + + expect(page).to have_css("#{js_dropdown_assignee} .filter-dropdown-loading", visible: true) + end + end + + it 'hides loading indicator when loaded' do + input_filtered_search('assignee=', submit: false, extra_space: false) + + expect(find(js_dropdown_assignee)).not_to have_css('.filter-dropdown-loading') + end + end + + describe 'caching requests' do + it 'caches requests after the first load' do + input_filtered_search('assignee=', submit: false, extra_space: false) + initial_size = dropdown_assignee_size + + expect(initial_size).to be > 0 + + new_user = create(:user) + project.add_maintainer(new_user) + find('.filtered-search-box .clear-search').click + input_filtered_search('assignee=', submit: false, extra_space: false) + + expect(dropdown_assignee_size).to eq(initial_size) + end + end +end diff --git a/spec/features/issues/filtered_search/dropdown_emoji_spec.rb b/spec/features/issues/filtered_search/dropdown_emoji_spec.rb index 7ec3d215fb17cbfd5075e3ff90d838a403478bfd..4c11f83318b888abc935e450b6fca1a977893253 100644 --- a/spec/features/issues/filtered_search/dropdown_emoji_spec.rb +++ b/spec/features/issues/filtered_search/dropdown_emoji_spec.rb @@ -11,30 +11,13 @@ describe 'Dropdown emoji', :js do let!(:award_emoji_star) { create(:award_emoji, name: 'star', user: user, awardable: issue) } let(:filtered_search) { find('.filtered-search') } let(:js_dropdown_emoji) { '#js-dropdown-my-reaction' } - - def send_keys_to_filtered_search(input) - input.split("").each do |i| - filtered_search.send_keys(i) - end - - sleep 0.5 - wait_for_requests - end - - def dropdown_emoji_size - all('gl-emoji[data-name]').size - end - - def click_emoji(text) - find('#js-dropdown-my-reaction .filter-dropdown .filter-dropdown-item', text: text).click - end + let(:filter_dropdown) { find("#{js_dropdown_emoji} .filter-dropdown") } before do project.add_maintainer(user) create_list(:award_emoji, 2, user: user, name: 'thumbsup') create_list(:award_emoji, 1, user: user, name: 'thumbsdown') create_list(:award_emoji, 3, user: user, name: 'star') - create_list(:award_emoji, 1, user: user, name: 'tea') end context 'when user not logged in' do @@ -43,8 +26,8 @@ describe 'Dropdown emoji', :js do end describe 'behavior' do - it 'does not open when the search bar has my-reaction:' do - filtered_search.set('my-reaction:') + it 'does not open when the search bar has my-reaction=' do + filtered_search.set('my-reaction=') expect(page).not_to have_css(js_dropdown_emoji) end @@ -59,143 +42,22 @@ describe 'Dropdown emoji', :js do end describe 'behavior' do - it 'opens when the search bar has my-reaction:' do - filtered_search.set('my-reaction:') + it 'opens when the search bar has my-reaction=' do + filtered_search.set('my-reaction=') expect(page).to have_css(js_dropdown_emoji, visible: true) end - it 'closes when the search bar is unfocused' do - find('body').click - - expect(page).to have_css(js_dropdown_emoji, visible: false) - end - - it 'shows loading indicator when opened' do - slow_requests do - filtered_search.set('my-reaction:') - - expect(page).to have_css('#js-dropdown-my-reaction .filter-dropdown-loading', visible: true) - end - end - - it 'hides loading indicator when loaded' do - send_keys_to_filtered_search('my-reaction:') - - expect(page).not_to have_css('#js-dropdown-my-reaction .filter-dropdown-loading') - end - it 'loads all the emojis when opened' do - send_keys_to_filtered_search('my-reaction:') + input_filtered_search('my-reaction=', submit: false, extra_space: false) - expect(dropdown_emoji_size).to eq(4) + expect_filtered_search_dropdown_results(filter_dropdown, 3) end it 'shows the most populated emoji at top of dropdown' do - send_keys_to_filtered_search('my-reaction:') - - expect(first('#js-dropdown-my-reaction .filter-dropdown li')).to have_content(award_emoji_star.name) - end - end - - describe 'filtering' do - before do - filtered_search.set('my-reaction') - send_keys_to_filtered_search(':') - end - - it 'filters by name' do - send_keys_to_filtered_search('up') - - expect(dropdown_emoji_size).to eq(1) - end - - it 'filters by case insensitive name' do - send_keys_to_filtered_search('Up') - - expect(dropdown_emoji_size).to eq(1) - end - end - - describe 'selecting from dropdown' do - before do - filtered_search.set('my-reaction') - send_keys_to_filtered_search(':') - end - - it 'selects `None`' do - find('#js-dropdown-my-reaction .filter-dropdown-item', text: 'None').click - - expect(page).to have_css(js_dropdown_emoji, visible: false) - expect_tokens([reaction_token('None', false)]) - expect_filtered_search_input_empty - end - - it 'selects `Any`' do - find('#js-dropdown-my-reaction .filter-dropdown-item', text: 'Any').click - - expect(page).to have_css(js_dropdown_emoji, visible: false) - expect_tokens([reaction_token('Any', false)]) - expect_filtered_search_input_empty - end - - it 'fills in the my-reaction name' do - click_emoji('thumbsup') - - wait_for_requests - - expect(page).to have_css(js_dropdown_emoji, visible: false) - expect_tokens([reaction_token('thumbsup')]) - expect_filtered_search_input_empty - end - end - - describe 'input has existing content' do - it 'opens my-reaction dropdown with existing search term' do - filtered_search.set('searchTerm my-reaction:') - - expect(page).to have_css(js_dropdown_emoji, visible: true) - end - - it 'opens my-reaction dropdown with existing assignee' do - filtered_search.set('assignee:@user my-reaction:') - - expect(page).to have_css(js_dropdown_emoji, visible: true) - end - - it 'opens my-reaction dropdown with existing label' do - filtered_search.set('label:~bug my-reaction:') - - expect(page).to have_css(js_dropdown_emoji, visible: true) - end - - it 'opens my-reaction dropdown with existing milestone' do - filtered_search.set('milestone:%v1.0 my-reaction:') - - expect(page).to have_css(js_dropdown_emoji, visible: true) - end - - it 'opens my-reaction dropdown with existing my-reaction' do - filtered_search.set('my-reaction:star my-reaction:') - - expect(page).to have_css(js_dropdown_emoji, visible: true) - end - end - - describe 'caching requests' do - it 'caches requests after the first load' do - filtered_search.set('my-reaction') - send_keys_to_filtered_search(':') - initial_size = dropdown_emoji_size - - expect(initial_size).to be > 0 - - create_list(:award_emoji, 1, user: user, name: 'smile') - find('.filtered-search-box .clear-search').click - filtered_search.set('my-reaction') - send_keys_to_filtered_search(':') + input_filtered_search('my-reaction=', submit: false, extra_space: false) - expect(dropdown_emoji_size).to eq(initial_size) + expect(first("#{js_dropdown_emoji} .filter-dropdown li")).to have_content(award_emoji_star.name) end end end diff --git a/spec/features/issues/filtered_search/dropdown_hint_spec.rb b/spec/features/issues/filtered_search/dropdown_hint_spec.rb index bb57d69148bc2edf922200c5a8139ab6bd3480ef..10b092c695730a3dc1c34333b576c3e63f8370e5 100644 --- a/spec/features/issues/filtered_search/dropdown_hint_spec.rb +++ b/spec/features/issues/filtered_search/dropdown_hint_spec.rb @@ -9,11 +9,16 @@ describe 'Dropdown hint', :js do let!(:user) { create(:user) } let(:filtered_search) { find('.filtered-search') } let(:js_dropdown_hint) { '#js-dropdown-hint' } + let(:js_dropdown_operator) { '#js-dropdown-operator' } def click_hint(text) find('#js-dropdown-hint .filter-dropdown .filter-dropdown-item', text: text).click end + def click_operator(op) + find("#js-dropdown-operator .filter-dropdown .filter-dropdown-item[data-value='#{op}']").click + end + before do project.add_maintainer(user) create(:issue, project: project) @@ -27,7 +32,7 @@ describe 'Dropdown hint', :js do it 'does not exist my-reaction dropdown item' do expect(page).to have_css(js_dropdown_hint, visible: false) - expect(page).not_to have_content('my-reaction') + expect(page).not_to have_content('My-reaction') end end @@ -46,9 +51,7 @@ describe 'Dropdown hint', :js do it 'opens when the search bar is first focused' do expect(page).to have_css(js_dropdown_hint, visible: true) - end - it 'closes when the search bar is unfocused' do find('body').click expect(page).to have_css(js_dropdown_hint, visible: false) @@ -56,15 +59,6 @@ describe 'Dropdown hint', :js do end describe 'filtering' do - it 'does not filter `Press Enter or click to search`' do - filtered_search.set('randomtext') - - hint_dropdown = find(js_dropdown_hint) - - expect(hint_dropdown).to have_content('Press Enter or click to search') - expect(hint_dropdown).to have_selector('.filter-dropdown .filter-dropdown-item', count: 0) - end - it 'filters with text' do filtered_search.set('a') @@ -77,189 +71,32 @@ describe 'Dropdown hint', :js do filtered_search.click end - it 'opens the author dropdown when you click on author' do - click_hint('author') - - expect(page).to have_css(js_dropdown_hint, visible: false) - expect(page).to have_css('#js-dropdown-author', visible: true) - expect_tokens([{ name: 'Author' }]) - expect_filtered_search_input_empty - end - - it 'opens the assignee dropdown when you click on assignee' do - click_hint('assignee') - - expect(page).to have_css(js_dropdown_hint, visible: false) - expect(page).to have_css('#js-dropdown-assignee', visible: true) - expect_tokens([{ name: 'Assignee' }]) - expect_filtered_search_input_empty - end - - it 'opens the milestone dropdown when you click on milestone' do - click_hint('milestone') - - expect(page).to have_css(js_dropdown_hint, visible: false) - expect(page).to have_css('#js-dropdown-milestone', visible: true) - expect_tokens([{ name: 'Milestone' }]) - expect_filtered_search_input_empty - end - - it 'opens the release dropdown when you click on release' do - click_hint('release') + it 'opens the token dropdown when you click on it' do + click_hint('Author') expect(page).to have_css(js_dropdown_hint, visible: false) - expect(page).to have_css('#js-dropdown-release', visible: true) - expect_tokens([{ name: 'Release' }]) - expect_filtered_search_input_empty - end - - it 'opens the label dropdown when you click on label' do - click_hint('label') - - expect(page).to have_css(js_dropdown_hint, visible: false) - expect(page).to have_css('#js-dropdown-label', visible: true) - expect_tokens([{ name: 'Label' }]) - expect_filtered_search_input_empty - end - - it 'opens the emoji dropdown when you click on my-reaction' do - click_hint('my-reaction') - - expect(page).to have_css(js_dropdown_hint, visible: false) - expect(page).to have_css('#js-dropdown-my-reaction', visible: true) - expect_tokens([{ name: 'My-reaction' }]) - expect_filtered_search_input_empty - end + expect(page).to have_css(js_dropdown_operator, visible: true) - it 'opens the yes-no dropdown when you click on confidential' do - click_hint('confidential') - - expect(page).to have_css(js_dropdown_hint, visible: false) - expect(page).to have_css('#js-dropdown-confidential', visible: true) - expect_tokens([{ name: 'Confidential' }]) - expect_filtered_search_input_empty - end - end - - describe 'selecting from dropdown with some input' do - it 'opens the author dropdown when you click on author' do - filtered_search.set('auth') - click_hint('author') + click_operator('=') expect(page).to have_css(js_dropdown_hint, visible: false) + expect(page).to have_css(js_dropdown_operator, visible: false) expect(page).to have_css('#js-dropdown-author', visible: true) - expect_tokens([{ name: 'Author' }]) - expect_filtered_search_input_empty - end - - it 'opens the assignee dropdown when you click on assignee' do - filtered_search.set('assign') - click_hint('assignee') - - expect(page).to have_css(js_dropdown_hint, visible: false) - expect(page).to have_css('#js-dropdown-assignee', visible: true) - expect_tokens([{ name: 'Assignee' }]) - expect_filtered_search_input_empty - end - - it 'opens the milestone dropdown when you click on milestone' do - filtered_search.set('mile') - click_hint('milestone') - - expect(page).to have_css(js_dropdown_hint, visible: false) - expect(page).to have_css('#js-dropdown-milestone', visible: true) - expect_tokens([{ name: 'Milestone' }]) - expect_filtered_search_input_empty - end - - it 'opens the label dropdown when you click on label' do - filtered_search.set('lab') - click_hint('label') - - expect(page).to have_css(js_dropdown_hint, visible: false) - expect(page).to have_css('#js-dropdown-label', visible: true) - expect_tokens([{ name: 'Label' }]) - expect_filtered_search_input_empty - end - - it 'opens the emoji dropdown when you click on my-reaction' do - filtered_search.set('my') - click_hint('my-reaction') - - expect(page).to have_css(js_dropdown_hint, visible: false) - expect(page).to have_css('#js-dropdown-my-reaction', visible: true) - expect_tokens([{ name: 'My-reaction' }]) + expect_tokens([{ name: 'Author', operator: '=' }]) expect_filtered_search_input_empty end end describe 'reselecting from dropdown' do - it 'reuses existing author text' do - filtered_search.send_keys('author:') + it 'reuses existing token text' do + filtered_search.send_keys('author') filtered_search.send_keys(:backspace) filtered_search.send_keys(:backspace) - click_hint('author') + click_hint('Author') expect_tokens([{ name: 'Author' }]) expect_filtered_search_input_empty end - - it 'reuses existing assignee text' do - filtered_search.send_keys('assignee:') - filtered_search.send_keys(:backspace) - filtered_search.send_keys(:backspace) - click_hint('assignee') - - expect_tokens([{ name: 'Assignee' }]) - expect_filtered_search_input_empty - end - - it 'reuses existing milestone text' do - filtered_search.send_keys('milestone:') - filtered_search.send_keys(:backspace) - filtered_search.send_keys(:backspace) - click_hint('milestone') - - expect_tokens([{ name: 'Milestone' }]) - expect_filtered_search_input_empty - end - - it 'reuses existing label text' do - filtered_search.send_keys('label:') - filtered_search.send_keys(:backspace) - filtered_search.send_keys(:backspace) - click_hint('label') - - expect_tokens([{ name: 'Label' }]) - expect_filtered_search_input_empty - end - - it 'reuses existing emoji text' do - filtered_search.send_keys('my-reaction:') - filtered_search.send_keys(:backspace) - filtered_search.send_keys(:backspace) - click_hint('my-reaction') - - expect_tokens([{ name: 'My-reaction' }]) - expect_filtered_search_input_empty - end - end - end - - context 'merge request page' do - before do - sign_in(user) - visit project_merge_requests_path(project) - filtered_search.click - end - - it 'shows the WIP menu item and opens the WIP options dropdown' do - click_hint('wip') - - expect(page).to have_css(js_dropdown_hint, visible: false) - expect(page).to have_css('#js-dropdown-wip', visible: true) - expect_tokens([{ name: 'WIP' }]) - expect_filtered_search_input_empty end end end diff --git a/spec/features/issues/filtered_search/dropdown_label_spec.rb b/spec/features/issues/filtered_search/dropdown_label_spec.rb index f7f9f0de4dbb3c746f99727879ac4e345e68a444..1e90efc8d565b4c729905cca726178f3f6f9ce67 100644 --- a/spec/features/issues/filtered_search/dropdown_label_spec.rb +++ b/spec/features/issues/filtered_search/dropdown_label_spec.rb @@ -8,31 +8,7 @@ describe 'Dropdown label', :js do let(:project) { create(:project) } let(:user) { create(:user) } let(:filtered_search) { find('.filtered-search') } - let(:js_dropdown_label) { '#js-dropdown-label' } - let(:filter_dropdown) { find("#{js_dropdown_label} .filter-dropdown") } - - shared_context 'with labels' do - let!(:bug_label) { create(:label, project: project, title: 'bug-label') } - let!(:uppercase_label) { create(:label, project: project, title: 'BUG-LABEL') } - let!(:two_words_label) { create(:label, project: project, title: 'High Priority') } - let!(:wont_fix_label) { create(:label, project: project, title: 'Won"t Fix') } - let!(:wont_fix_single_label) { create(:label, project: project, title: 'Won\'t Fix') } - let!(:special_label) { create(:label, project: project, title: '!@#$%^+&*()') } - let!(:long_label) { create(:label, project: project, title: 'this is a very long title this is a very long title this is a very long title this is a very long title this is a very long title') } - end - - def search_for_label(label) - init_label_search - filtered_search.send_keys(label) - end - - def click_label(text) - filter_dropdown.find('.filter-dropdown-item', text: text).click - end - - def clear_search_field - find('.filtered-search-box .clear-search').click - end + let(:filter_dropdown) { find('#js-dropdown-label .filter-dropdown') } before do project.add_maintainer(user) @@ -42,267 +18,12 @@ describe 'Dropdown label', :js do visit project_issues_path(project) end - describe 'keyboard navigation' do - it 'selects label' do - bug_label = create(:label, project: project, title: 'bug-label') - init_label_search - - # 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 - end - end - describe 'behavior' do - it 'opens when the search bar has label:' do - filtered_search.set('label:') - - expect(page).to have_css(js_dropdown_label) - end - - it 'closes when the search bar is unfocused' do - find('body').click - - expect(page).not_to have_css(js_dropdown_label) - end - - it 'shows loading indicator when opened and hides it when loaded' do - slow_requests do - filtered_search.set('label:') - - expect(page).to have_css("#{js_dropdown_label} .filter-dropdown-loading", visible: true) - end - expect(find(js_dropdown_label)).not_to have_css('.filter-dropdown-loading') - end - it 'loads all the labels when opened' do - bug_label = create(:label, project: project, title: 'bug-label') - filtered_search.set('label:') - - expect(filter_dropdown).to have_content(bug_label.title) - expect(filter_dropdown).to have_selector('.filter-dropdown-item', count: 1) - end - end - - describe 'filtering' do - include_context 'with labels' - - before do - init_label_search - end - - it 'filters by case-insensitive name with or without symbol' do - filtered_search.send_keys('b') - - expect(filter_dropdown.find('.filter-dropdown-item', text: bug_label.title)).to be_visible - expect(filter_dropdown.find('.filter-dropdown-item', text: uppercase_label.title)).to be_visible - - expect(filter_dropdown).to have_selector('.filter-dropdown-item', count: 2) - - clear_search_field - init_label_search - - filtered_search.send_keys('~bu') - - expect(filter_dropdown.find('.filter-dropdown-item', text: bug_label.title)).to be_visible - expect(filter_dropdown.find('.filter-dropdown-item', text: uppercase_label.title)).to be_visible - expect(filter_dropdown).to have_selector('.filter-dropdown-item', count: 2) - end - - it 'filters by multiple words with or without symbol' do - filtered_search.send_keys('Hig') - - expect(filter_dropdown.find('.filter-dropdown-item', text: two_words_label.title)).to be_visible - expect(filter_dropdown).to have_selector('.filter-dropdown-item', count: 1) - - clear_search_field - init_label_search - - filtered_search.send_keys('~Hig') - - expect(filter_dropdown.find('.filter-dropdown-item', text: two_words_label.title)).to be_visible - expect(filter_dropdown).to have_selector('.filter-dropdown-item', count: 1) - end - - it 'filters by multiple words containing single quotes with or without symbol' do - filtered_search.send_keys('won\'t') - - expect(filter_dropdown.find('.filter-dropdown-item', text: wont_fix_single_label.title)).to be_visible - expect(filter_dropdown).to have_selector('.filter-dropdown-item', count: 1) - - clear_search_field - init_label_search - - filtered_search.send_keys('~won\'t') - - expect(filter_dropdown.find('.filter-dropdown-item', text: wont_fix_single_label.title)).to be_visible - expect(filter_dropdown).to have_selector('.filter-dropdown-item', count: 1) - end - - it 'filters by multiple words containing double quotes with or without symbol' do - filtered_search.send_keys('won"t') - - expect(filter_dropdown.find('.filter-dropdown-item', text: wont_fix_label.title)).to be_visible - expect(filter_dropdown).to have_selector('.filter-dropdown-item', count: 1) - - clear_search_field - init_label_search - - filtered_search.send_keys('~won"t') - - expect(filter_dropdown.find('.filter-dropdown-item', text: wont_fix_label.title)).to be_visible - expect(filter_dropdown).to have_selector('.filter-dropdown-item', count: 1) - end - - it 'filters by special characters with or without symbol' do - filtered_search.send_keys('^+') - - expect(filter_dropdown.find('.filter-dropdown-item', text: special_label.title)).to be_visible - expect(filter_dropdown).to have_selector('.filter-dropdown-item', count: 1) - - clear_search_field - init_label_search - - filtered_search.send_keys('~^+') - - expect(filter_dropdown.find('.filter-dropdown-item', text: special_label.title)).to be_visible - expect(filter_dropdown).to have_selector('.filter-dropdown-item', count: 1) - end - end - - describe 'selecting from dropdown' do - include_context 'with labels' - - before do - init_label_search - end - - it 'fills in the label name when the label has not been filled' do - click_label(bug_label.title) - - expect(page).not_to have_css(js_dropdown_label) - expect_tokens([label_token(bug_label.title)]) - expect_filtered_search_input_empty - end - - it 'fills in the label name when the label is partially filled' do - filtered_search.send_keys('bu') - click_label(bug_label.title) - - expect(page).not_to have_css(js_dropdown_label) - expect_tokens([label_token(bug_label.title)]) - expect_filtered_search_input_empty - end - - it 'fills in the label name that contains multiple words' do - click_label(two_words_label.title) - - expect(page).not_to have_css(js_dropdown_label) - expect_tokens([label_token("\"#{two_words_label.title}\"")]) - expect_filtered_search_input_empty - end - - it 'fills in the label name that contains multiple words and is very long' do - click_label(long_label.title) - - expect(page).not_to have_css(js_dropdown_label) - expect_tokens([label_token("\"#{long_label.title}\"")]) - expect_filtered_search_input_empty - end - - it 'fills in the label name that contains double quotes' do - click_label(wont_fix_label.title) - - expect(page).not_to have_css(js_dropdown_label) - expect_tokens([label_token("'#{wont_fix_label.title}'")]) - expect_filtered_search_input_empty - end - - it 'fills in the label name with the correct capitalization' do - click_label(uppercase_label.title) - - expect(page).not_to have_css(js_dropdown_label) - expect_tokens([label_token(uppercase_label.title)]) - expect_filtered_search_input_empty - end - - it 'fills in the label name with special characters' do - click_label(special_label.title) - - expect(page).not_to have_css(js_dropdown_label) - expect_tokens([label_token(special_label.title)]) - expect_filtered_search_input_empty - end - - it 'selects `no label`' do - 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 - it 'opens label dropdown with existing search term' do - filtered_search.set('searchTerm label:') - - expect(page).to have_css(js_dropdown_label) - end - - it 'opens label dropdown with existing author' do - filtered_search.set('author:@person label:') - - expect(page).to have_css(js_dropdown_label) - end - - it 'opens label dropdown with existing assignee' do - filtered_search.set('assignee:@person label:') - - expect(page).to have_css(js_dropdown_label) - end - - it 'opens label dropdown with existing label' do - filtered_search.set('label:~urgent label:') - - expect(page).to have_css(js_dropdown_label) - end - - it 'opens label dropdown with existing milestone' do - filtered_search.set('milestone:%v2.0 label:') - - expect(page).to have_css(js_dropdown_label) - end - - it 'opens label dropdown with existing my-reaction' do - filtered_search.set('my-reaction:star label:') - - expect(page).to have_css(js_dropdown_label) - end - end - - describe 'caching requests' do - it 'caches requests after the first load' do create(:label, project: project, title: 'bug-label') - init_label_search - - expect(filter_dropdown).to have_selector('.filter-dropdown-item', count: 1) - - create(:label, project: project) - clear_search_field - init_label_search + filtered_search.set('label=') - expect(filter_dropdown).to have_selector('.filter-dropdown-item', count: 1) + expect_filtered_search_dropdown_results(filter_dropdown, 1) end end end diff --git a/spec/features/issues/filtered_search/dropdown_milestone_spec.rb b/spec/features/issues/filtered_search/dropdown_milestone_spec.rb index 5272a970a609f873b81db206fa244b10a5fd31c0..1f62a8e0c8d7d1c4cf6b0714db22a163e6f453f0 100644 --- a/spec/features/issues/filtered_search/dropdown_milestone_spec.rb +++ b/spec/features/issues/filtered_search/dropdown_milestone_spec.rb @@ -9,26 +9,9 @@ describe 'Dropdown milestone', :js do let!(:user) { create(:user) } let!(:milestone) { create(:milestone, title: 'v1.0', project: project) } let!(:uppercase_milestone) { create(:milestone, title: 'CAP_MILESTONE', project: project) } - let!(:two_words_milestone) { create(:milestone, title: 'Future Plan', project: project) } - let!(:wont_fix_milestone) { create(:milestone, title: 'Won"t Fix', project: project) } - let!(:special_milestone) { create(:milestone, title: '!@#$%^&*(+)', project: project) } - let!(:long_milestone) { create(:milestone, title: 'this is a very long title this is a very long title this is a very long title this is a very long title this is a very long title', project: project) } let(:filtered_search) { find('.filtered-search') } - let(:js_dropdown_milestone) { '#js-dropdown-milestone' } - let(:filter_dropdown) { find("#{js_dropdown_milestone} .filter-dropdown") } - - def dropdown_milestone_size - filter_dropdown.all('.filter-dropdown-item').size - end - - def click_milestone(text) - find('#js-dropdown-milestone .filter-dropdown .filter-dropdown-item', text: text).click - end - - def click_static_milestone(text) - find('#js-dropdown-milestone .filter-dropdown-item', text: text).click - end + let(:filter_dropdown) { find('#js-dropdown-milestone .filter-dropdown') } before do project.add_maintainer(user) @@ -39,240 +22,12 @@ describe 'Dropdown milestone', :js do end describe 'behavior' do - context 'filters by "milestone:"' do - before do - filtered_search.set('milestone:') - end - - it 'opens when the search bar has milestone:' do - expect(page).to have_css(js_dropdown_milestone, visible: true) - end - - it 'closes when the search bar is unfocused' do - find('body').click - - expect(page).to have_css(js_dropdown_milestone, visible: false) - end - - it 'hides loading indicator when loaded' do - expect(find(js_dropdown_milestone)).not_to have_css('.filter-dropdown-loading') - end - - it 'loads all the milestones when opened' do - expect(filter_dropdown).to have_selector('.filter-dropdown .filter-dropdown-item', count: 6) - end - end - - it 'shows loading indicator when opened' do - slow_requests do - filtered_search.set('milestone:') - - expect(page).to have_css('#js-dropdown-milestone .filter-dropdown-loading', visible: true) - end - end - end - - describe 'filtering' do before do - filtered_search.set('milestone:') - - expect(find("#{js_dropdown_milestone} .filter-dropdown")).to have_content(milestone.title) - expect(find("#{js_dropdown_milestone} .filter-dropdown")).to have_content(uppercase_milestone.title) - expect(find("#{js_dropdown_milestone} .filter-dropdown")).to have_content(two_words_milestone.title) - expect(find("#{js_dropdown_milestone} .filter-dropdown")).to have_content(wont_fix_milestone.title) - expect(find("#{js_dropdown_milestone} .filter-dropdown")).to have_content(special_milestone.title) - expect(find("#{js_dropdown_milestone} .filter-dropdown")).to have_content(long_milestone.title) - end - - it 'filters by name' do - filtered_search.send_keys('v1') - - expect(filter_dropdown).to have_selector('.filter-dropdown .filter-dropdown-item', count: 1) - end - - it 'filters by case insensitive name' do - filtered_search.send_keys('V1') - - expect(filter_dropdown).to have_selector('.filter-dropdown .filter-dropdown-item', count: 1) + filtered_search.set('milestone=') end - it 'filters by name with symbol' do - filtered_search.send_keys('%v1') - - expect(filter_dropdown).to have_selector('.filter-dropdown .filter-dropdown-item', count: 1) - end - - it 'filters by case insensitive name with symbol' do - filtered_search.send_keys('%V1') - - expect(filter_dropdown).to have_selector('.filter-dropdown .filter-dropdown-item', count: 1) - end - - it 'filters by special characters' do - filtered_search.send_keys('(+') - - expect(filter_dropdown).to have_selector('.filter-dropdown .filter-dropdown-item', count: 1) - end - - it 'filters by special characters with symbol' do - filtered_search.send_keys('%(+') - - expect(filter_dropdown).to have_selector('.filter-dropdown .filter-dropdown-item', count: 1) - end - end - - describe 'selecting from dropdown' do - before do - filtered_search.set('milestone:') - - expect(find("#{js_dropdown_milestone} .filter-dropdown")).to have_content(milestone.title) - expect(find("#{js_dropdown_milestone} .filter-dropdown")).to have_content(uppercase_milestone.title) - expect(find("#{js_dropdown_milestone} .filter-dropdown")).to have_content(two_words_milestone.title) - expect(find("#{js_dropdown_milestone} .filter-dropdown")).to have_content(wont_fix_milestone.title) - expect(find("#{js_dropdown_milestone} .filter-dropdown")).to have_content(special_milestone.title) - expect(find("#{js_dropdown_milestone} .filter-dropdown")).to have_content(long_milestone.title) - end - - it 'fills in the milestone name when the milestone has not been filled' do - click_milestone(milestone.title) - - expect(page).to have_css(js_dropdown_milestone, visible: false) - expect_tokens([milestone_token(milestone.title)]) - expect_filtered_search_input_empty - end - - it 'fills in the milestone name when the milestone is partially filled', :quarantine do - filtered_search.send_keys('v') - click_milestone(milestone.title) - - expect(page).to have_css(js_dropdown_milestone, visible: false) - expect_tokens([milestone_token(milestone.title)]) - expect_filtered_search_input_empty - end - - it 'fills in the milestone name that contains multiple words' do - click_milestone(two_words_milestone.title) - - expect(page).to have_css(js_dropdown_milestone, visible: false) - expect_tokens([milestone_token("\"#{two_words_milestone.title}\"")]) - expect_filtered_search_input_empty - end - - it 'fills in the milestone name that contains multiple words and is very long' do - click_milestone(long_milestone.title) - - expect(page).to have_css(js_dropdown_milestone, visible: false) - expect_tokens([milestone_token("\"#{long_milestone.title}\"")]) - expect_filtered_search_input_empty - end - - it 'fills in the milestone name that contains double quotes' do - click_milestone(wont_fix_milestone.title) - - expect(page).to have_css(js_dropdown_milestone, visible: false) - expect_tokens([milestone_token("'#{wont_fix_milestone.title}'")]) - expect_filtered_search_input_empty - end - - it 'fills in the milestone name with the correct capitalization' do - click_milestone(uppercase_milestone.title) - - expect(page).to have_css(js_dropdown_milestone, visible: false) - expect_tokens([milestone_token(uppercase_milestone.title)]) - expect_filtered_search_input_empty - end - - it 'fills in the milestone name with special characters' do - click_milestone(special_milestone.title) - - expect(page).to have_css(js_dropdown_milestone, visible: false) - expect_tokens([milestone_token(special_milestone.title)]) - expect_filtered_search_input_empty - end - - it 'selects `no milestone`' do - click_static_milestone('None') - - expect(page).to have_css(js_dropdown_milestone, visible: false) - expect_tokens([milestone_token('None', false)]) - expect_filtered_search_input_empty - end - - it 'selects `any milestone`' do - click_static_milestone('Any') - - expect(page).to have_css(js_dropdown_milestone, visible: false) - expect_tokens([milestone_token('Any', false)]) - expect_filtered_search_input_empty - end - - it 'selects `upcoming milestone`' do - click_static_milestone('Upcoming') - - expect(page).to have_css(js_dropdown_milestone, visible: false) - expect_tokens([milestone_token('Upcoming', false)]) - expect_filtered_search_input_empty - end - - it 'selects `started milestones`' do - click_static_milestone('Started') - - expect(page).to have_css(js_dropdown_milestone, visible: false) - expect_tokens([milestone_token('Started', false)]) - expect_filtered_search_input_empty - end - end - - describe 'input has existing content' do - it 'opens milestone dropdown with existing search term' do - filtered_search.set('searchTerm milestone:') - - expect(page).to have_css(js_dropdown_milestone, visible: true) - end - - it 'opens milestone dropdown with existing author' do - filtered_search.set('author:@john milestone:') - - expect(page).to have_css(js_dropdown_milestone, visible: true) - end - - it 'opens milestone dropdown with existing assignee' do - filtered_search.set('assignee:@john milestone:') - - expect(page).to have_css(js_dropdown_milestone, visible: true) - end - - it 'opens milestone dropdown with existing label' do - filtered_search.set('label:~important milestone:') - - expect(page).to have_css(js_dropdown_milestone, visible: true) - end - - it 'opens milestone dropdown with existing milestone' do - filtered_search.set('milestone:%100 milestone:') - - expect(page).to have_css(js_dropdown_milestone, visible: true) - end - - it 'opens milestone dropdown with existing my-reaction' do - filtered_search.set('my-reaction:star milestone:') - - expect(page).to have_css(js_dropdown_milestone, visible: true) - end - end - - describe 'caching requests' do - it 'caches requests after the first load' do - filtered_search.set('milestone:') - initial_size = dropdown_milestone_size - - expect(initial_size).to be > 0 - - create(:milestone, project: project) - find('.filtered-search-box .clear-search').click - filtered_search.set('milestone:') - - expect(dropdown_milestone_size).to eq(initial_size) + it 'loads all the milestones when opened' do + expect_filtered_search_dropdown_results(filter_dropdown, 2) end end end diff --git a/spec/features/issues/filtered_search/dropdown_release_spec.rb b/spec/features/issues/filtered_search/dropdown_release_spec.rb index eea7f2d784886b02aee52762843098a7dbe254c0..fd0a98f9ddc936789c9bb9d697048f59219c3dd4 100644 --- a/spec/features/issues/filtered_search/dropdown_release_spec.rb +++ b/spec/features/issues/filtered_search/dropdown_release_spec.rb @@ -10,13 +10,8 @@ describe 'Dropdown release', :js do let!(:release) { create(:release, tag: 'v1.0', project: project) } let!(:crazy_release) { create(:release, tag: '☺!/"#%&\'{}+,-.<>;=@]_`{|}🚀', project: project) } - def filtered_search - find('.filtered-search') - end - - def filter_dropdown - find('#js-dropdown-release .filter-dropdown') - end + let(:filtered_search) { find('.filtered-search') } + let(:filter_dropdown) { find('#js-dropdown-release .filter-dropdown') } before do project.add_maintainer(user) @@ -28,28 +23,11 @@ describe 'Dropdown release', :js do describe 'behavior' do before do - filtered_search.set('release:') - end - - def expect_results(count) - expect(filter_dropdown).to have_selector('.filter-dropdown .filter-dropdown-item', count: count) + filtered_search.set('release=') end it 'loads all the releases when opened' do - expect_results(2) - end - - it 'filters by tag name' do - filtered_search.send_keys("☺") - expect_results(1) - end - - it 'fills in the release name when the autocomplete hint is clicked' do - find('#js-dropdown-release .filter-dropdown-item', text: crazy_release.tag).click - - expect(page).to have_css('#js-dropdown-release', visible: false) - expect_tokens([release_token(crazy_release.tag)]) - expect_filtered_search_input_empty + expect_filtered_search_dropdown_results(filter_dropdown, 2) end end end diff --git a/spec/features/issues/filtered_search/filter_issues_spec.rb b/spec/features/issues/filtered_search/filter_issues_spec.rb index 8b5e7934ec10d51499b6d759b4ba541a20922b6b..c99c205d5dab859c19f7f65c9c7104020e896f23 100644 --- a/spec/features/issues/filtered_search/filter_issues_spec.rb +++ b/spec/features/issues/filtered_search/filter_issues_spec.rb @@ -67,7 +67,7 @@ describe 'Filter issues', :js do it 'filters by all available tokens' do search_term = 'issue' - input_filtered_search("assignee:@#{user.username} author:@#{user.username} label:~#{caps_sensitive_label.title} milestone:%#{milestone.title} #{search_term}") + input_filtered_search("assignee=@#{user.username} author=@#{user.username} label=~#{caps_sensitive_label.title} milestone=%#{milestone.title} #{search_term}") wait_for_requests @@ -84,7 +84,7 @@ describe 'Filter issues', :js do describe 'filter issues by author' do context 'only author' do it 'filters issues by searched author' do - input_filtered_search("author:@#{user.username}") + input_filtered_search("author=@#{user.username}") wait_for_requests @@ -98,7 +98,7 @@ describe 'Filter issues', :js do describe 'filter issues by assignee' do context 'only assignee' do it 'filters issues by searched assignee' do - input_filtered_search("assignee:@#{user.username}") + input_filtered_search("assignee=@#{user.username}") wait_for_requests @@ -108,7 +108,7 @@ describe 'Filter issues', :js do end it 'filters issues by no assignee' do - input_filtered_search('assignee:none') + input_filtered_search('assignee=none') expect_tokens([assignee_token('None')]) expect_issues_list_count(3) @@ -122,7 +122,7 @@ describe 'Filter issues', :js do it 'filters issues by multiple assignees' do create(:issue, project: project, author: user, assignees: [user2, user]) - input_filtered_search("assignee:@#{user.username} assignee:@#{user2.username}") + input_filtered_search("assignee=@#{user.username} assignee=@#{user2.username}") expect_tokens([ assignee_token(user.name), @@ -138,15 +138,31 @@ describe 'Filter issues', :js do describe 'filter issues by label' do context 'only label' do it 'filters issues by searched label' do - input_filtered_search("label:~#{bug_label.title}") + input_filtered_search("label=~#{bug_label.title}") expect_tokens([label_token(bug_label.title)]) expect_issues_list_count(2) expect_filtered_search_input_empty end + it 'filters issues not containing searched label' do + input_filtered_search("label!=~#{bug_label.title}") + + expect_tokens([label_token(bug_label.title)]) + expect_issues_list_count(6) + expect_filtered_search_input_empty + end + it 'filters issues by no label' do - input_filtered_search('label:none') + input_filtered_search('label=none') + + expect_tokens([label_token('None', false)]) + expect_issues_list_count(4) + expect_filtered_search_input_empty + end + + it 'filters issues by no label' do + input_filtered_search('label!=none') expect_tokens([label_token('None', false)]) expect_issues_list_count(4) @@ -154,7 +170,18 @@ describe 'Filter issues', :js do end it 'filters issues by multiple labels' do - input_filtered_search("label:~#{bug_label.title} label:~#{caps_sensitive_label.title}") + input_filtered_search("label=~#{bug_label.title} label=~#{caps_sensitive_label.title}") + + expect_tokens([ + label_token(bug_label.title), + label_token(caps_sensitive_label.title) + ]) + expect_issues_list_count(1) + expect_filtered_search_input_empty + end + + it 'filters issues by multiple labels with not operator' do + input_filtered_search("label!=~#{bug_label.title} label=~#{caps_sensitive_label.title}") expect_tokens([ label_token(bug_label.title), @@ -169,22 +196,42 @@ describe 'Filter issues', :js do special_issue = create(:issue, title: "Issue with special character label", project: project) special_issue.labels << special_label - input_filtered_search("label:~#{special_label.title}") + input_filtered_search("label=~#{special_label.title}") expect_tokens([label_token(special_label.title)]) expect_issues_list_count(1) expect_filtered_search_input_empty end + it 'filters issues by label not containing special characters' do + special_label = create(:label, project: project, title: '!@#{$%^&*()-+[]<>?/:{}|\}') + special_issue = create(:issue, title: "Issue with special character label", project: project) + special_issue.labels << special_label + + input_filtered_search("label!=~#{special_label.title}") + + expect_tokens([label_token(special_label.title)]) + expect_issues_list_count(8) + expect_filtered_search_input_empty + end + it 'does not show issues for unused labels' do new_label = create(:label, project: project, title: 'new_label') - input_filtered_search("label:~#{new_label.title}") + input_filtered_search("label=~#{new_label.title}") expect_tokens([label_token(new_label.title)]) expect_no_issues_list expect_filtered_search_input_empty end + + it 'does show issues for bug label' do + input_filtered_search("label!=~#{bug_label.title}") + + expect_tokens([label_token(bug_label.title)]) + expect_issues_list_count(6) + expect_filtered_search_input_empty + end end context 'label with multiple words' do @@ -193,7 +240,7 @@ describe 'Filter issues', :js do special_multiple_issue = create(:issue, title: "Issue with special character multiple words label", project: project) special_multiple_issue.labels << special_multiple_label - input_filtered_search("label:~'#{special_multiple_label.title}'") + input_filtered_search("label=~'#{special_multiple_label.title}'") # Check for search results (which makes sure that the page has changed) expect_issues_list_count(1) @@ -205,7 +252,7 @@ describe 'Filter issues', :js do end it 'single quotes' do - input_filtered_search("label:~'#{multiple_words_label.title}'") + input_filtered_search("label=~'#{multiple_words_label.title}'") expect_issues_list_count(1) expect_tokens([label_token("\"#{multiple_words_label.title}\"")]) @@ -213,7 +260,7 @@ describe 'Filter issues', :js do end it 'double quotes' do - input_filtered_search("label:~\"#{multiple_words_label.title}\"") + input_filtered_search("label=~\"#{multiple_words_label.title}\"") expect_tokens([label_token("\"#{multiple_words_label.title}\"")]) expect_issues_list_count(1) @@ -225,7 +272,7 @@ describe 'Filter issues', :js do double_quotes_label_issue = create(:issue, title: "Issue with double quotes label", project: project) double_quotes_label_issue.labels << double_quotes_label - input_filtered_search("label:~'#{double_quotes_label.title}'") + input_filtered_search("label=~'#{double_quotes_label.title}'") expect_tokens([label_token("'#{double_quotes_label.title}'")]) expect_issues_list_count(1) @@ -237,7 +284,7 @@ describe 'Filter issues', :js do single_quotes_label_issue = create(:issue, title: "Issue with single quotes label", project: project) single_quotes_label_issue.labels << single_quotes_label - input_filtered_search("label:~\"#{single_quotes_label.title}\"") + input_filtered_search("label=~\"#{single_quotes_label.title}\"") expect_tokens([label_token("\"#{single_quotes_label.title}\"")]) expect_issues_list_count(1) @@ -249,7 +296,7 @@ describe 'Filter issues', :js do it 'filters issues by searched label, label2, author, assignee, milestone and text' do search_term = 'bug' - input_filtered_search("label:~#{bug_label.title} label:~#{caps_sensitive_label.title} author:@#{user.username} assignee:@#{user.username} milestone:%#{milestone.title} #{search_term}") + input_filtered_search("label=~#{bug_label.title} label=~#{caps_sensitive_label.title} author=@#{user.username} assignee=@#{user.username} milestone=%#{milestone.title} #{search_term}") wait_for_requests @@ -263,6 +310,24 @@ describe 'Filter issues', :js do expect_issues_list_count(1) expect_filtered_search_input(search_term) end + + it 'filters issues by searched label, label2, author, assignee, not included in a milestone' do + search_term = 'bug' + + input_filtered_search("label=~#{bug_label.title} label=~#{caps_sensitive_label.title} author=@#{user.username} assignee=@#{user.username} milestone!=%#{milestone.title} #{search_term}") + + wait_for_requests + + expect_tokens([ + label_token(bug_label.title), + label_token(caps_sensitive_label.title), + author_token(user.name), + assignee_token(user.name), + milestone_token(milestone.title, false, '!=') + ]) + expect_issues_list_count(0) + expect_filtered_search_input(search_term) + end end context 'issue label clicked' do @@ -279,7 +344,7 @@ describe 'Filter issues', :js do describe 'filter issues by milestone' do context 'only milestone' do it 'filters issues by searched milestone' do - input_filtered_search("milestone:%#{milestone.title}") + input_filtered_search("milestone=%#{milestone.title}") expect_tokens([milestone_token(milestone.title)]) expect_issues_list_count(5) @@ -287,53 +352,102 @@ describe 'Filter issues', :js do end it 'filters issues by no milestone' do - input_filtered_search("milestone:none") + input_filtered_search("milestone=none") expect_tokens([milestone_token('None', false)]) expect_issues_list_count(3) expect_filtered_search_input_empty end + it 'filters issues by negation of no milestone' do + input_filtered_search("milestone!=none ") + + expect_tokens([milestone_token('None', false, '!=')]) + expect_issues_list_count(5) + expect_filtered_search_input_empty + end + it 'filters issues by upcoming milestones' do create(:milestone, project: project, due_date: 1.month.from_now) do |future_milestone| create(:issue, project: project, milestone: future_milestone, author: user) end - input_filtered_search("milestone:upcoming") + input_filtered_search("milestone=upcoming") expect_tokens([milestone_token('Upcoming', false)]) expect_issues_list_count(1) expect_filtered_search_input_empty end + it 'filters issues by negation of upcoming milestones' do + create(:milestone, project: project, due_date: 1.month.from_now) do |future_milestone| + create(:issue, project: project, milestone: future_milestone, author: user) + end + + input_filtered_search("milestone!=upcoming") + + expect_tokens([milestone_token('Upcoming', false, '!=')]) + expect_issues_list_count(8) + expect_filtered_search_input_empty + end + it 'filters issues by started milestones' do - input_filtered_search("milestone:started") + input_filtered_search("milestone=started") expect_tokens([milestone_token('Started', false)]) expect_issues_list_count(5) expect_filtered_search_input_empty end + it 'filters issues by negation of started milestones' do + input_filtered_search("milestone!=started") + + expect_tokens([milestone_token('Started', false, '!=')]) + expect_issues_list_count(3) + expect_filtered_search_input_empty + end + it 'filters issues by milestone containing special characters' do special_milestone = create(:milestone, title: '!@\#{$%^&*()}', project: project) create(:issue, project: project, milestone: special_milestone) - input_filtered_search("milestone:%#{special_milestone.title}") + input_filtered_search("milestone=%#{special_milestone.title}") expect_tokens([milestone_token(special_milestone.title)]) expect_issues_list_count(1) expect_filtered_search_input_empty end + it 'filters issues by milestone not containing special characters' do + special_milestone = create(:milestone, title: '!@\#{$%^&*()}', project: project) + create(:issue, project: project, milestone: special_milestone) + + input_filtered_search("milestone!=%#{special_milestone.title}") + + expect_tokens([milestone_token(special_milestone.title, false, '!=')]) + expect_issues_list_count(8) + expect_filtered_search_input_empty + end + it 'does not show issues for unused milestones' do new_milestone = create(:milestone, title: 'new', project: project) - input_filtered_search("milestone:%#{new_milestone.title}") + input_filtered_search("milestone=%#{new_milestone.title}") expect_tokens([milestone_token(new_milestone.title)]) expect_no_issues_list expect_filtered_search_input_empty end + + it 'show issues for unused milestones' do + new_milestone = create(:milestone, title: 'new', project: project) + + input_filtered_search("milestone!=%#{new_milestone.title}") + + expect_tokens([milestone_token(new_milestone.title, false, '!=')]) + expect_issues_list_count(8) + expect_filtered_search_input_empty + end end end @@ -407,7 +521,7 @@ describe 'Filter issues', :js do context 'searched text with other filters' do it 'filters issues by searched text, author, text, assignee, text, label1, text, label2, text, milestone and text' do - input_filtered_search("bug author:@#{user.username} report label:~#{bug_label.title} label:~#{caps_sensitive_label.title} milestone:%#{milestone.title} foo") + input_filtered_search("bug author=@#{user.username} report label=~#{bug_label.title} label=~#{caps_sensitive_label.title} milestone=%#{milestone.title} foo") expect_issues_list_count(1) expect_filtered_search_input('bug report foo') @@ -475,65 +589,13 @@ describe 'Filter issues', :js do end end - describe 'RSS feeds' do - let(:group) { create(:group) } - let(:project) { create(:project, group: group) } - - before do - group.add_developer(user) - end - - shared_examples 'updates atom feed link' do |type| - it "for #{type}" do - visit path - - link = find_link('Subscribe to RSS feed') - params = CGI.parse(URI.parse(link[:href]).query) - auto_discovery_link = find('link[type="application/atom+xml"]', visible: false) - auto_discovery_params = CGI.parse(URI.parse(auto_discovery_link[:href]).query) - - expected = { - 'feed_token' => [user.feed_token], - 'milestone_title' => [milestone.title], - 'assignee_id' => [user.id.to_s] - } - - expect(params).to include(expected) - expect(auto_discovery_params).to include(expected) - end - end - - it_behaves_like 'updates atom feed link', :project do - let(:path) { project_issues_path(project, milestone_title: milestone.title, assignee_id: user.id) } - end - - it_behaves_like 'updates atom feed link', :group do - let(:path) { issues_group_path(group, milestone_title: milestone.title, assignee_id: user.id) } - end - - it 'updates atom feed link for group issues' do - visit issues_group_path(group, milestone_title: milestone.title, assignee_id: user.id) - link = find('.nav-controls a[title="Subscribe to RSS feed"]', visible: false) - params = CGI.parse(URI.parse(link[:href]).query) - auto_discovery_link = find('link[type="application/atom+xml"]', visible: false) - auto_discovery_params = CGI.parse(URI.parse(auto_discovery_link[:href]).query) - - expect(params).to include('feed_token' => [user.feed_token]) - expect(params).to include('milestone_title' => [milestone.title]) - expect(params).to include('assignee_id' => [user.id.to_s]) - expect(auto_discovery_params).to include('feed_token' => [user.feed_token]) - expect(auto_discovery_params).to include('milestone_title' => [milestone.title]) - expect(auto_discovery_params).to include('assignee_id' => [user.id.to_s]) - end - end - context 'URL has a trailing slash' do before do visit "#{project_issues_path(project)}/" end it 'milestone dropdown loads milestones' do - input_filtered_search("milestone:", submit: false) + input_filtered_search("milestone=", submit: false) within('#js-dropdown-milestone') do expect(page).to have_selector('.filter-dropdown .filter-dropdown-item', count: 1) @@ -541,7 +603,7 @@ describe 'Filter issues', :js do end it 'label dropdown load labels' do - input_filtered_search("label:", submit: false) + input_filtered_search("label=", submit: false) within('#js-dropdown-label') do expect(page).to have_selector('.filter-dropdown .filter-dropdown-item', count: 3) diff --git a/spec/features/issues/filtered_search/recent_searches_spec.rb b/spec/features/issues/filtered_search/recent_searches_spec.rb index c038281d82556fac3ae67408bc5871bd3e4226f5..e05c7aa3af53116d2a394978bd261c48deb857cc 100644 --- a/spec/features/issues/filtered_search/recent_searches_spec.rb +++ b/spec/features/issues/filtered_search/recent_searches_spec.rb @@ -41,8 +41,8 @@ describe 'Recent searches', :js do items = all('.filtered-search-history-dropdown-item', visible: false, count: 2) - expect(items[0].text).to eq('label: ~qux garply') - expect(items[1].text).to eq('label: ~foo bar') + expect(items[0].text).to eq('label: = ~qux garply') + expect(items[1].text).to eq('label: = ~foo bar') end it 'saved recent searches are restored last on the list' do diff --git a/spec/features/issues/filtered_search/search_bar_spec.rb b/spec/features/issues/filtered_search/search_bar_spec.rb index e97314e02e61167221c9791cce82f84e0251d66c..ad99427021846a5d9bc6dbf176b40fd604b3a460 100644 --- a/spec/features/issues/filtered_search/search_bar_spec.rb +++ b/spec/features/issues/filtered_search/search_bar_spec.rb @@ -34,7 +34,7 @@ describe 'Search bar', :js do it 'selects item' do filtered_search.native.send_keys(:down, :down, :enter) - expect_tokens([author_token]) + expect_tokens([{ name: 'Assignee' }]) expect_filtered_search_input_empty end end @@ -78,7 +78,7 @@ describe 'Search bar', :js do filtered_search.click original_size = page.all('#js-dropdown-hint .filter-dropdown .filter-dropdown-item').size - filtered_search.set('author') + filtered_search.set('autho') expect(find('#js-dropdown-hint')).to have_selector('.filter-dropdown .filter-dropdown-item', count: 1) diff --git a/spec/features/issues/filtered_search/visual_tokens_spec.rb b/spec/features/issues/filtered_search/visual_tokens_spec.rb index f8035ef4b8595c23c55163b7e6d9972bf20691b0..2af2e096bcc454cbb0d395e4b02532d71c60ec16 100644 --- a/spec/features/issues/filtered_search/visual_tokens_spec.rb +++ b/spec/features/issues/filtered_search/visual_tokens_spec.rb @@ -34,17 +34,15 @@ describe 'Visual tokens', :js do visit project_issues_path(project) end - describe 'editing author token' do + describe 'editing a single token' do before do - input_filtered_search('author:@root assignee:none', submit: false) + input_filtered_search('author=@root assignee=none', submit: false) first('.tokens-container .filtered-search-token').click + wait_for_requests end it 'opens author dropdown' do expect(page).to have_css('#js-dropdown-author', visible: true) - end - - it 'makes value editable' do expect_filtered_search_input('@root') end @@ -77,143 +75,10 @@ describe 'Visual tokens', :js do end end - describe 'editing assignee token' do - before do - input_filtered_search('assignee:@root author:none', submit: false) - first('.tokens-container .filtered-search-token').double_click - end - - it 'opens assignee dropdown' do - expect(page).to have_css('#js-dropdown-assignee', visible: true) - end - - it 'makes value editable' do - expect_filtered_search_input('@root') - end - - it 'filters value' do - filtered_search.send_keys(:backspace) - - expect(page).to have_css('#js-dropdown-assignee .filter-dropdown .filter-dropdown-item', count: 1) - end - - it 'ends editing mode when document is clicked' do - find('#content-body').click - - expect_filtered_search_input_empty - expect(page).to have_css('#js-dropdown-assignee', visible: false) - end - - describe 'selecting static option from dropdown' do - before do - find("#js-dropdown-assignee").find('.filter-dropdown-item', text: 'None').click - end - - it 'changes value in visual token' do - expect(first('.tokens-container .filtered-search-token .value').text).to eq('None') - end - - it 'moves input to the right' do - expect(is_input_focused).to eq(true) - end - end - end - - describe 'editing milestone token' do - before do - input_filtered_search('milestone:%10.0 author:none', submit: false) - first('.tokens-container .filtered-search-token').click - first('#js-dropdown-milestone .filter-dropdown .filter-dropdown-item') - end - - it 'opens milestone dropdown' do - expect(filter_milestone_dropdown.find('.filter-dropdown-item', text: milestone_ten.title)).to be_visible - expect(filter_milestone_dropdown.find('.filter-dropdown-item', text: milestone_nine.title)).to be_visible - expect(page).to have_css('#js-dropdown-milestone', visible: true) - end - - it 'selects static option from dropdown' do - find("#js-dropdown-milestone").find('.filter-dropdown-item', text: 'Upcoming').click - - expect(first('.tokens-container .filtered-search-token .value').text).to eq('Upcoming') - expect(is_input_focused).to eq(true) - end - - it 'makes value editable' do - expect_filtered_search_input('%10.0') - end - - it 'filters value' do - filtered_search.send_keys(:backspace) - - expect(page).to have_css('#js-dropdown-milestone .filter-dropdown .filter-dropdown-item', count: 1) - end - - it 'ends editing mode when document is clicked' do - find('#content-body').click - - expect_filtered_search_input_empty - expect(page).to have_css('#js-dropdown-milestone', visible: false) - end - end - - describe 'editing label token' do - before do - input_filtered_search("label:~#{label.title} author:none", submit: false) - first('.tokens-container .filtered-search-token').double_click - first('#js-dropdown-label .filter-dropdown .filter-dropdown-item') - end - - it 'opens label dropdown' do - expect(filter_label_dropdown.find('.filter-dropdown-item', text: label.title)).to be_visible - expect(filter_label_dropdown.find('.filter-dropdown-item', text: cc_label.title)).to be_visible - expect(page).to have_css('#js-dropdown-label', visible: true) - end - - it 'selects option from dropdown' do - expect(filter_label_dropdown.find('.filter-dropdown-item', text: label.title)).to be_visible - expect(filter_label_dropdown.find('.filter-dropdown-item', text: cc_label.title)).to be_visible - - find("#js-dropdown-label").find('.filter-dropdown-item', text: cc_label.title).click - - expect(first('.tokens-container .filtered-search-token .value').text).to eq("~\"#{cc_label.title}\"") - expect(is_input_focused).to eq(true) - end - - it 'makes value editable' do - expect_filtered_search_input("~#{label.title}") - end - - it 'filters value' do - expect(filter_label_dropdown.find('.filter-dropdown-item', text: label.title)).to be_visible - expect(filter_label_dropdown.find('.filter-dropdown-item', text: cc_label.title)).to be_visible - - filtered_search.send_keys(:backspace) - - filter_label_dropdown.find('.filter-dropdown-item') - - expect(page.all('#js-dropdown-label .filter-dropdown .filter-dropdown-item').size).to eq(1) - end - - it 'ends editing mode when document is clicked' do - find('#content-body').click - - expect_filtered_search_input_empty - expect(page).to have_css('#js-dropdown-label', visible: false) - end - - it 'ends editing mode when scroll container is clicked' do - find('.scroll-container').click - - expect_filtered_search_input_empty - expect(page).to have_css('#js-dropdown-label', visible: false) - end - end - describe 'editing multiple tokens' do before do - input_filtered_search('author:@root assignee:none', submit: false) - first('.tokens-container .filtered-search-token').double_click + input_filtered_search('author=@root assignee=none', submit: false) + first('.tokens-container .filtered-search-token').click end it 'opens author dropdown' do @@ -221,31 +86,33 @@ describe 'Visual tokens', :js do end it 'opens assignee dropdown' do - find('.tokens-container .filtered-search-token', text: 'Assignee').double_click + find('.tokens-container .filtered-search-token', text: 'Assignee').click expect(page).to have_css('#js-dropdown-assignee', visible: true) end end describe 'editing a search term while editing another filter token' do before do - input_filtered_search('author assignee:', submit: false) - first('.tokens-container .filtered-search-term').double_click - end - - it 'opens hint dropdown' do - expect(page).to have_css('#js-dropdown-hint', visible: true) + input_filtered_search('foo assignee=', submit: false) + first('.tokens-container .filtered-search-term').click end it 'opens author dropdown' do - find('#js-dropdown-hint .filter-dropdown .filter-dropdown-item', text: 'author').click + find('#js-dropdown-hint .filter-dropdown .filter-dropdown-item', text: 'Author').click + + expect(page).to have_css('#js-dropdown-operator', visible: true) + expect(page).to have_css('#js-dropdown-author', visible: false) + find('#js-dropdown-operator .filter-dropdown .filter-dropdown-item[data-value="="]').click + + expect(page).to have_css('#js-dropdown-operator', visible: false) expect(page).to have_css('#js-dropdown-author', visible: true) end end describe 'add new token after editing existing token' do before do - input_filtered_search('author:@root assignee:none', submit: false) + input_filtered_search('author=@root assignee=none', submit: false) first('.tokens-container .filtered-search-token').double_click filtered_search.send_keys(' ') end @@ -255,63 +122,25 @@ describe 'Visual tokens', :js do expect(page).to have_css('#js-dropdown-hint', visible: true) end - it 'opens author dropdown' do - filtered_search.send_keys('author:') - expect(page).to have_css('#js-dropdown-author', visible: true) - end - - it 'opens assignee dropdown' do - filtered_search.send_keys('assignee:') - expect(page).to have_css('#js-dropdown-assignee', visible: true) - end - - it 'opens milestone dropdown' do - filtered_search.send_keys('milestone:') - expect(page).to have_css('#js-dropdown-milestone', visible: true) - end + it 'opens token dropdown' do + filtered_search.send_keys('author=') - it 'opens label dropdown' do - filtered_search.send_keys('label:') - expect(page).to have_css('#js-dropdown-label', visible: true) + expect(page).to have_css('#js-dropdown-author', visible: true) end end - describe 'creates visual tokens' do - it 'creates author token' do - filtered_search.send_keys('author:@thomas ') + describe 'visual tokens' do + it 'creates visual token' do + filtered_search.send_keys('author=@thomas ') token = page.all('.tokens-container .filtered-search-token')[1] expect(token.find('.name').text).to eq('Author') expect(token.find('.value').text).to eq('@thomas') end - - it 'creates assignee token' do - filtered_search.send_keys('assignee:@thomas ') - token = page.all('.tokens-container .filtered-search-token')[1] - - expect(token.find('.name').text).to eq('Assignee') - expect(token.find('.value').text).to eq('@thomas') - end - - it 'creates milestone token' do - filtered_search.send_keys('milestone:none ') - token = page.all('.tokens-container .filtered-search-token')[1] - - expect(token.find('.name').text).to eq('Milestone') - expect(token.find('.value').text).to eq('none') - end - - it 'creates label token' do - filtered_search.send_keys('label:~Backend ') - token = page.all('.tokens-container .filtered-search-token')[1] - - expect(token.find('.name').text).to eq('Label') - expect(token.find('.value').text).to eq('~Backend') - end end it 'does not tokenize incomplete token' do - filtered_search.send_keys('author:') + filtered_search.send_keys('author=') find('body').click token = page.all('.tokens-container .js-visual-token')[1] @@ -323,7 +152,7 @@ describe 'Visual tokens', :js do describe 'search using incomplete visual tokens' do before do - input_filtered_search('author:@root assignee:none', extra_space: false) + input_filtered_search('author=@root assignee=none', extra_space: false) end it 'tokenizes the search term to complete visual token' do diff --git a/spec/features/issues/rss_spec.rb b/spec/features/issues/rss_spec.rb index d6a406f4f4448319c2e6f0b049deb760ca025d75..7577df3bc7d375b647fe6cd364f32c4c70ea6dfb 100644 --- a/spec/features/issues/rss_spec.rb +++ b/spec/features/issues/rss_spec.rb @@ -3,11 +3,14 @@ require 'spec_helper' describe 'Project Issues RSS' do - let(:project) { create(:project, visibility_level: Gitlab::VisibilityLevel::PUBLIC) } + let!(:user) { create(:user) } + let(:group) { create(:group) } + let(:project) { create(:project, group: group, visibility_level: Gitlab::VisibilityLevel::PUBLIC) } let(:path) { project_issues_path(project) } before do - create(:issue, project: project) + create(:issue, project: project, assignees: [user]) + group.add_developer(user) end context 'when signed in' do @@ -31,4 +34,34 @@ describe 'Project Issues RSS' do it_behaves_like "it has an RSS button without a feed token" it_behaves_like "an autodiscoverable RSS feed without a feed token" end + + describe 'feeds' do + shared_examples 'updates atom feed link' do |type| + it "for #{type}" do + sign_in(user) + visit path + + link = find_link('Subscribe to RSS feed') + params = CGI.parse(URI.parse(link[:href]).query) + auto_discovery_link = find('link[type="application/atom+xml"]', visible: false) + auto_discovery_params = CGI.parse(URI.parse(auto_discovery_link[:href]).query) + + expected = { + 'feed_token' => [user.feed_token], + 'assignee_id' => [user.id.to_s] + } + + expect(params).to include(expected) + expect(auto_discovery_params).to include(expected) + end + end + + it_behaves_like 'updates atom feed link', :project do + let(:path) { project_issues_path(project, assignee_id: user.id) } + end + + it_behaves_like 'updates atom feed link', :group do + let(:path) { issues_group_path(group, assignee_id: user.id) } + end + end end diff --git a/spec/features/issues/user_comments_on_issue_spec.rb b/spec/features/issues/user_comments_on_issue_spec.rb index 829f945c47fbcf234639ec894e81e27bc2229782..363906b017a4d9a3944c242ff2b9a000cf584e7b 100644 --- a/spec/features/issues/user_comments_on_issue_spec.rb +++ b/spec/features/issues/user_comments_on_issue_spec.rb @@ -43,17 +43,17 @@ describe "User comments on issue", :js do expect(page.find('pre code').text).to eq code_block_content end - it "renders escaped HTML content in Mermaid" do + it "renders HTML content as text in Mermaid" do html_content = "<img onerror=location=`javascript\\u003aalert\\u0028document.domain\\u0029` src=x>" mermaid_content = "graph LR\n B-->D(#{html_content});" - escaped_content = CGI.escapeHTML(html_content).gsub('=', "=") comment = "```mermaid\n#{mermaid_content}\n```" add_note(comment) wait_for_requests - expect(page.find('svg.mermaid')).to have_content escaped_content + expect(page.find('svg.mermaid')).to have_content html_content + within('svg.mermaid') { expect(page).not_to have_selector('img') } end it 'opens autocomplete menu for quick actions and have `/label` first choice' do diff --git a/spec/features/issues/user_creates_issue_by_email_spec.rb b/spec/features/issues/user_creates_issue_by_email_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..c73a65849cca42c708b6504aacc8d4c8917699e7 --- /dev/null +++ b/spec/features/issues/user_creates_issue_by_email_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'Issues > User creates issue by email' do + let_it_be(:user) { create(:user) } + let_it_be(:project) { create(:project, :public) } + + before do + sign_in(user) + + project.add_developer(user) + end + + describe 'new issue by email' do + shared_examples 'show the email in the modal' do + let(:issue) { create(:issue, project: project) } + + before do + project.issues << issue + stub_incoming_email_setting(enabled: true, address: "p+%{key}@gl.ab") + + visit project_issues_path(project) + click_button('Email a new issue') + end + + it 'click the button to show modal for the new email' do + page.within '#issuable-email-modal' do + email = project.new_issuable_address(user, 'issue') + + expect(page).to have_selector("input[value='#{email}']") + end + end + end + + context 'with existing issues' do + let!(:issue) { create(:issue, project: project, author: user) } + + it_behaves_like 'show the email in the modal' + end + + context 'without existing issues' do + it_behaves_like 'show the email in the modal' + end + end +end diff --git a/spec/features/issues/user_creates_issue_spec.rb b/spec/features/issues/user_creates_issue_spec.rb index 39ce3415727187023661e1e6ab57ef9272c51ec0..b0a2a7348770726234782c5c8429a0ce37ce6a83 100644 --- a/spec/features/issues/user_creates_issue_spec.rb +++ b/spec/features/issues/user_creates_issue_spec.rb @@ -3,8 +3,32 @@ require "spec_helper" describe "User creates issue" do - let(:project) { create(:project_empty_repo, :public) } - let(:user) { create(:user) } + include DropzoneHelper + + let_it_be(:project) { create(:project_empty_repo, :public) } + let_it_be(:user) { create(:user) } + + context "when unauthenticated" do + before do + sign_out(:user) + end + + it "redirects to signin then back to new issue after signin" do + create(:issue, project: project) + + visit project_issues_path(project) + + page.within ".nav-controls" do + click_link "New issue" + end + + expect(current_path).to eq new_user_session_path + + gitlab_sign_in(create(:user)) + + expect(current_path).to eq new_project_issue_path(project) + end + end context "when signed in as guest" do before do @@ -92,6 +116,104 @@ describe "User creates issue" do .and have_content(label_titles.first) end end + + context 'with due date', :js do + it 'saves with due date' do + date = Date.today.at_beginning_of_month + + fill_in 'issue_title', with: 'bug 345' + fill_in 'issue_description', with: 'bug description' + find('#issuable-due-date').click + + page.within '.pika-single' do + click_button date.day + end + + expect(find('#issuable-due-date').value).to eq date.to_s + + click_button 'Submit issue' + + page.within '.issuable-sidebar' do + expect(page).to have_content date.to_s(:medium) + end + end + end + + context 'dropzone upload file', :js do + before do + visit new_project_issue_path(project) + end + + it 'uploads file when dragging into textarea' do + dropzone_file Rails.root.join('spec', 'fixtures', 'banana_sample.gif') + + expect(page.find_field("issue_description").value).to have_content 'banana_sample' + end + + it "doesn't add double newline to end of a single attachment markdown" do + dropzone_file Rails.root.join('spec', 'fixtures', 'banana_sample.gif') + + expect(page.find_field("issue_description").value).not_to match /\n\n$/ + end + + it "cancels a file upload correctly" do + slow_requests do + dropzone_file([Rails.root.join('spec', 'fixtures', 'dk.png')], 0, false) + + click_button 'Cancel' + end + + expect(page).to have_button('Attach a file') + expect(page).not_to have_button('Cancel') + expect(page).not_to have_selector('.uploading-progress-container', visible: true) + end + end + + context 'form filled by URL parameters' do + let(:project) { create(:project, :public, :repository) } + + before do + project.repository.create_file( + user, + '.gitlab/issue_templates/bug.md', + 'this is a test "bug" template', + message: 'added issue template', + branch_name: 'master') + + visit new_project_issue_path(project, issuable_template: 'bug') + end + + it 'fills in template' do + expect(find('.js-issuable-selector .dropdown-toggle-text')).to have_content('bug') + end + end + + context 'suggestions', :js do + it 'displays list of related issues' do + issue = create(:issue, project: project) + create(:issue, project: project, title: 'test issue') + + visit new_project_issue_path(project) + + fill_in 'issue_title', with: issue.title + + expect(page).to have_selector('.suggestion-item', count: 1) + end + end + + it 'clears local storage after creating a new issue', :js do + 2.times do + visit new_project_issue_path(project) + wait_for_requests + + expect(page).to have_field('Title', with: '') + + fill_in 'issue_title', with: 'bug 345' + fill_in 'issue_description', with: 'bug description' + + click_button 'Submit issue' + end + end end context "when signed in as user with special characters in their name" do diff --git a/spec/features/issues/user_edits_issue_spec.rb b/spec/features/issues/user_edits_issue_spec.rb index 0afc19d9519b6cbcf8e852728224d16255f57fd5..ad984cf07e2bdf1e91d404b03677c3de8f54f3ff 100644 --- a/spec/features/issues/user_edits_issue_spec.rb +++ b/spec/features/issues/user_edits_issue_spec.rb @@ -2,26 +2,283 @@ require "spec_helper" -describe "User edits issue", :js do - set(:project) { create(:project_empty_repo, :public) } - set(:user) { create(:user) } - set(:issue) { create(:issue, project: project, author: user) } +describe "Issues > User edits issue", :js do + let_it_be(:project) { create(:project_empty_repo, :public) } + let_it_be(:user) { create(:user) } + let_it_be(:issue) { create(:issue, project: project, author: user, assignees: [user]) } + let_it_be(:label) { create(:label, project: project) } + let_it_be(:milestone) { create(:milestone, project: project) } before do project.add_developer(user) sign_in(user) + end + + context "from edit page" do + before do + visit edit_project_issue_path(project, issue) + end + + it "previews content" do + form = first(".gfm-form") + + page.within(form) do + fill_in("Description", with: "Bug fixed :smile:") + click_button("Preview") + end + + expect(form).to have_button("Write") + end + + it 'allows user to select unassigned' do + visit edit_project_issue_path(project, issue) + + expect(page).to have_content "Assignee #{user.name}" + + first('.js-user-search').click + click_link 'Unassigned' + + click_button 'Save changes' + + page.within('.assignee') do + expect(page).to have_content 'None - assign yourself' + end + end + + context 'with due date' do + before do + visit edit_project_issue_path(project, issue) + end + + it 'saves with due date' do + date = Date.today.at_beginning_of_month.tomorrow + + fill_in 'issue_title', with: 'bug 345' + fill_in 'issue_description', with: 'bug description' + find('#issuable-due-date').click + + page.within '.pika-single' do + click_button date.day + end + + expect(find('#issuable-due-date').value).to eq date.to_s + + click_button 'Save changes' - visit(edit_project_issue_path(project, issue)) + page.within '.issuable-sidebar' do + expect(page).to have_content date.to_s(:medium) + end + end + + it 'warns about version conflict' do + issue.update(title: "New title") + + fill_in 'issue_title', with: 'bug 345' + fill_in 'issue_description', with: 'bug description' + + click_button 'Save changes' + + expect(page).to have_content 'Someone edited the issue the same time you did' + end + end end - it "previews content" do - form = first(".gfm-form") + context "from issue#show" do + before do + visit project_issue_path(project, issue) + end + + describe 'update labels' do + it 'will not send ajax request when no data is changed' do + page.within '.labels' do + click_link 'Edit' - page.within(form) do - fill_in("Description", with: "Bug fixed :smile:") - click_button("Preview") + find('.dropdown-menu-close', match: :first).click + + expect(page).not_to have_selector('.block-loading') + end + end end - expect(form).to have_button("Write") + describe 'update assignee' do + context 'by authorized user' do + def close_dropdown_menu_if_visible + find('.dropdown-menu-toggle', visible: :all).tap do |toggle| + toggle.click if toggle.visible? + end + end + + it 'allows user to select unassigned' do + visit project_issue_path(project, issue) + + page.within('.assignee') do + expect(page).to have_content "#{user.name}" + + click_link 'Edit' + click_link 'Unassigned' + first('.title').click + expect(page).to have_content 'None - assign yourself' + end + end + + it 'allows user to select an assignee' do + issue2 = create(:issue, project: project, author: user) + visit project_issue_path(project, issue2) + + page.within('.assignee') do + expect(page).to have_content "None" + end + + page.within '.assignee' do + click_link 'Edit' + end + + page.within '.dropdown-menu-user' do + click_link user.name + end + + page.within('.assignee') do + expect(page).to have_content user.name + end + end + + it 'allows user to unselect themselves' do + issue2 = create(:issue, project: project, author: user) + + visit project_issue_path(project, issue2) + + page.within '.assignee' do + click_link 'Edit' + click_link user.name + + close_dropdown_menu_if_visible + + page.within '.value .author' do + expect(page).to have_content user.name + end + + click_link 'Edit' + click_link user.name + + close_dropdown_menu_if_visible + + page.within '.value .assign-yourself' do + expect(page).to have_content "None" + end + end + end + end + + context 'by unauthorized user' do + let(:guest) { create(:user) } + + before do + project.add_guest(guest) + end + + it 'shows assignee text' do + sign_out(:user) + sign_in(guest) + + visit project_issue_path(project, issue) + expect(page).to have_content issue.assignees.first.name + end + end + end + + describe 'update milestone' do + context 'by authorized user' do + it 'allows user to select unassigned' do + visit project_issue_path(project, issue) + + page.within('.milestone') do + expect(page).to have_content "None" + end + + find('.block.milestone .edit-link').click + sleep 2 # wait for ajax stuff to complete + first('.dropdown-content li').click + sleep 2 + page.within('.milestone') do + expect(page).to have_content 'None' + end + end + + it 'allows user to de-select milestone' do + visit project_issue_path(project, issue) + + page.within('.milestone') do + click_link 'Edit' + click_link milestone.title + + page.within '.value' do + expect(page).to have_content milestone.title + end + + click_link 'Edit' + click_link milestone.title + + page.within '.value' do + expect(page).to have_content 'None' + end + end + end + end + + context 'by unauthorized user' do + let(:guest) { create(:user) } + + before do + project.add_guest(guest) + issue.milestone = milestone + issue.save + end + + it 'shows milestone text' do + sign_out(:user) + sign_in(guest) + + visit project_issue_path(project, issue) + expect(page).to have_content milestone.title + end + end + end + + context 'update due date' do + it 'adds due date to issue' do + date = Date.today.at_beginning_of_month + 2.days + + page.within '.due_date' do + click_link 'Edit' + + page.within '.pika-single' do + click_button date.day + end + + wait_for_requests + + expect(find('.value').text).to have_content date.strftime('%b %-d, %Y') + end + end + + it 'removes due date from issue' do + date = Date.today.at_beginning_of_month + 2.days + + page.within '.due_date' do + click_link 'Edit' + + page.within '.pika-single' do + click_button date.day + end + + wait_for_requests + + expect(page).to have_no_content 'None' + + click_link 'remove due date' + expect(page).to have_content 'None' + end + end + end end end diff --git a/spec/features/issues/user_filters_issues_spec.rb b/spec/features/issues/user_filters_issues_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..714bc9720254fb9e8fd3f91816d18e43f74ea2e6 --- /dev/null +++ b/spec/features/issues/user_filters_issues_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'User filters issues' do + let_it_be(:user) { create(:user) } + let_it_be(:project) { create(:project_empty_repo, :public) } + + before do + %w[foobar barbaz].each do |title| + create(:issue, + author: user, + assignees: [user], + project: project, + title: title) + end + + @issue = Issue.find_by(title: 'foobar') + @issue.milestone = create(:milestone, project: project) + @issue.assignees = [] + @issue.save + end + + let(:issue) { @issue } + + it 'allows filtering by issues with no specified assignee' do + visit project_issues_path(project, assignee_id: IssuableFinder::FILTER_NONE) + + expect(page).to have_content 'foobar' + expect(page).not_to have_content 'barbaz' + end + + it 'allows filtering by a specified assignee' do + visit project_issues_path(project, assignee_id: user.id) + + expect(page).not_to have_content 'foobar' + expect(page).to have_content 'barbaz' + end +end diff --git a/spec/features/issues/user_resets_their_incoming_email_token_spec.rb b/spec/features/issues/user_resets_their_incoming_email_token_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..108b6f550dbf42310b98378f8a42fefdad9e78b0 --- /dev/null +++ b/spec/features/issues/user_resets_their_incoming_email_token_spec.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'Issues > User resets their incoming email token' do + let_it_be(:user) { create(:user) } + let_it_be(:project) { create(:project, :public, namespace: user.namespace) } + let_it_be(:issue) { create(:issue, project: project) } + + before do + stub_incoming_email_setting(enabled: true, address: "p+%{key}@gl.ab") + project.add_maintainer(user) + sign_in(user) + + visit namespace_project_issues_path(user.namespace, project) + end + + it 'changes incoming email address token', :js do + find('.issuable-email-modal-btn').click + previous_token = find('input#issuable_email').value + find('.incoming-email-token-reset').click + + wait_for_requests + + expect(page).to have_no_field('issuable_email', with: previous_token) + new_token = project.new_issuable_address(user.reload, 'issue') + expect(page).to have_field( + 'issuable_email', + with: new_token + ) + end +end diff --git a/spec/features/issues/user_sees_breadcrumb_links_spec.rb b/spec/features/issues/user_sees_breadcrumb_links_spec.rb index f31d730c337350f5a918a3d49d7db72c656ada76..8a120a0a0b23dbb8860488ed9d2c3ea542395491 100644 --- a/spec/features/issues/user_sees_breadcrumb_links_spec.rb +++ b/spec/features/issues/user_sees_breadcrumb_links_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' describe 'New issue breadcrumb' do - let(:project) { create(:project) } + let_it_be(:project, reload: true) { create(:project) } let(:user) { project.creator } before do @@ -17,4 +17,22 @@ describe 'New issue breadcrumb' do expect(find_link('New')[:href]).to end_with(new_project_issue_path(project)) end end + + it 'links to current issue in breadcrubs' do + issue = create(:issue, project: project) + + visit project_issue_path(project, issue) + + expect(find('.breadcrumbs-sub-title a')[:href]).to end_with(issue_path(issue)) + end + + it 'excludes award_emoji from comment count' do + issue = create(:issue, author: user, assignees: [user], project: project, title: 'foobar') + create(:award_emoji, awardable: issue) + + visit project_issues_path(project, assignee_id: user.id) + + expect(page).to have_content 'foobar' + expect(page.all('.no-comments').first.text).to eq "0" + end end diff --git a/spec/features/issues/user_sees_empty_state_spec.rb b/spec/features/issues/user_sees_empty_state_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..114d119aca8ae6c2b0b20253c0a85fdc8e2f0dc5 --- /dev/null +++ b/spec/features/issues/user_sees_empty_state_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'Issues > User sees empty state' do + let_it_be(:project) { create(:project, :public) } + let_it_be(:user) { project.creator } + + shared_examples_for 'empty state with filters' do + it 'user sees empty state with filters' do + create(:issue, author: user, project: project) + + visit project_issues_path(project, milestone_title: "1.0") + + expect(page).to have_content('Sorry, your filter produced no results') + expect(page).to have_content('To widen your search, change or remove filters above') + end + end + + describe 'while user is signed out' do + describe 'empty state' do + it 'user sees empty state' do + visit project_issues_path(project) + + expect(page).to have_content('Register / Sign In') + expect(page).to have_content('The Issue Tracker is the place to add things that need to be improved or solved in a project.') + expect(page).to have_content('You can register or sign in to create issues for this project.') + end + + it_behaves_like 'empty state with filters' + end + end + + describe 'while user is signed in' do + before do + sign_in(user) + end + + describe 'empty state' do + it 'user sees empty state' do + visit project_issues_path(project) + + expect(page).to have_content('The Issue Tracker is the place to add things that need to be improved or solved in a project') + expect(page).to have_content('Issues can be bugs, tasks or ideas to be discussed. Also, issues are searchable and filterable.') + expect(page).to have_content('New issue') + end + + it_behaves_like 'empty state with filters' + end + end +end diff --git a/spec/features/issues/user_sees_live_update_spec.rb b/spec/features/issues/user_sees_live_update_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..98c7d289fb010f7c9738227c94ed355a9046dfd9 --- /dev/null +++ b/spec/features/issues/user_sees_live_update_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'Issues > User sees live update', :js do + let_it_be(:project) { create(:project, :public) } + let_it_be(:user) { project.creator } + + before do + sign_in(user) + end + + describe 'title issue#show' do + it 'updates the title' do + issue = create(:issue, author: user, assignees: [user], project: project, title: 'new title') + + visit project_issue_path(project, issue) + + expect(page).to have_text("new title") + + issue.update(title: "updated title") + + wait_for_requests + expect(page).to have_text("updated title") + end + end + + describe 'confidential issue#show' do + it 'shows confidential sibebar information as confidential and can be turned off' do + issue = create(:issue, :confidential, project: project) + + visit project_issue_path(project, issue) + + expect(page).to have_css('.issuable-note-warning') + expect(find('.issuable-sidebar-item.confidentiality')).to have_css('.is-active') + expect(find('.issuable-sidebar-item.confidentiality')).not_to have_css('.not-active') + + find('.confidential-edit').click + expect(page).to have_css('.sidebar-item-warning-message') + + within('.sidebar-item-warning-message') do + find('.btn-close').click + end + + wait_for_requests + + visit project_issue_path(project, issue) + + expect(page).not_to have_css('.is-active') + end + end +end diff --git a/spec/features/issues/user_sorts_issues_spec.rb b/spec/features/issues/user_sorts_issues_spec.rb index 79938785633c8b7d4a24e8325e69beccf11f3c69..66110f55435d0b0e9c3c62279409c2036083f741 100644 --- a/spec/features/issues/user_sorts_issues_spec.rb +++ b/spec/features/issues/user_sorts_issues_spec.rb @@ -3,12 +3,17 @@ require "spec_helper" describe "User sorts issues" do - set(:user) { create(:user) } - set(:group) { create(:group) } - set(:project) { create(:project_empty_repo, :public, group: group) } - set(:issue1) { create(:issue, project: project) } - set(:issue2) { create(:issue, project: project) } - set(:issue3) { create(:issue, project: project) } + include SortingHelper + include IssueHelpers + + let_it_be(:user) { create(:user) } + let_it_be(:group) { create(:group) } + let_it_be(:project) { create(:project_empty_repo, :public, group: group) } + let_it_be(:issue1, reload: true) { create(:issue, title: 'foo', created_at: Time.now, project: project) } + let_it_be(:issue2, reload: true) { create(:issue, title: 'bar', created_at: Time.now - 60, project: project) } + let_it_be(:issue3, reload: true) { create(:issue, title: 'baz', created_at: Time.now - 120, project: project) } + let_it_be(:newer_due_milestone) { create(:milestone, project: project, due_date: '2013-12-11') } + let_it_be(:later_due_milestone) { create(:milestone, project: project, due_date: '2013-12-12') } before do create_list(:award_emoji, 2, :upvote, awardable: issue1) @@ -62,4 +67,174 @@ describe "User sorts issues" do end end end + + it 'sorts by newest' do + visit project_issues_path(project, sort: sort_value_created_date) + + expect(first_issue).to include('foo') + expect(last_issue).to include('baz') + end + + it 'sorts by most recently updated' do + issue3.updated_at = Time.now + 100 + issue3.save + visit project_issues_path(project, sort: sort_value_recently_updated) + + expect(first_issue).to include('baz') + end + + describe 'sorting by due date' do + before do + issue1.update(due_date: 1.day.from_now) + issue2.update(due_date: 6.days.from_now) + end + + it 'sorts by due date' do + visit project_issues_path(project, sort: sort_value_due_date) + + expect(first_issue).to include('foo') + end + + it 'sorts by due date by excluding nil due dates' do + issue2.update(due_date: nil) + + visit project_issues_path(project, sort: sort_value_due_date) + + expect(first_issue).to include('foo') + end + + context 'with a filter on labels' do + let(:label) { create(:label, project: project) } + + before do + create(:label_link, label: label, target: issue1) + end + + it 'sorts by least recently due date by excluding nil due dates' do + issue2.update(due_date: nil) + + visit project_issues_path(project, label_names: [label.name], sort: sort_value_due_date_later) + + expect(first_issue).to include('foo') + end + end + end + + describe 'filtering by due date' do + before do + issue1.update(due_date: 1.day.from_now) + issue2.update(due_date: 6.days.from_now) + end + + it 'filters by none' do + visit project_issues_path(project, due_date: Issue::NoDueDate.name) + + page.within '.issues-holder' do + expect(page).not_to have_content('foo') + expect(page).not_to have_content('bar') + expect(page).to have_content('baz') + end + end + + it 'filters by any' do + visit project_issues_path(project, due_date: Issue::AnyDueDate.name) + + page.within '.issues-holder' do + expect(page).to have_content('foo') + expect(page).to have_content('bar') + expect(page).to have_content('baz') + end + end + + it 'filters by due this week' do + issue1.update(due_date: Date.today.beginning_of_week + 2.days) + issue2.update(due_date: Date.today.end_of_week) + issue3.update(due_date: Date.today - 8.days) + + visit project_issues_path(project, due_date: Issue::DueThisWeek.name) + + page.within '.issues-holder' do + expect(page).to have_content('foo') + expect(page).to have_content('bar') + expect(page).not_to have_content('baz') + end + end + + it 'filters by due this month' do + issue1.update(due_date: Date.today.beginning_of_month + 2.days) + issue2.update(due_date: Date.today.end_of_month) + issue3.update(due_date: Date.today - 50.days) + + visit project_issues_path(project, due_date: Issue::DueThisMonth.name) + + page.within '.issues-holder' do + expect(page).to have_content('foo') + expect(page).to have_content('bar') + expect(page).not_to have_content('baz') + end + end + + it 'filters by overdue' do + issue1.update(due_date: Date.today + 2.days) + issue2.update(due_date: Date.today + 20.days) + issue3.update(due_date: Date.yesterday) + + visit project_issues_path(project, due_date: Issue::Overdue.name) + + page.within '.issues-holder' do + expect(page).not_to have_content('foo') + expect(page).not_to have_content('bar') + expect(page).to have_content('baz') + end + end + + it 'filters by due next month and previous two weeks' do + issue1.update(due_date: Date.today - 4.weeks) + issue2.update(due_date: (Date.today + 2.months).beginning_of_month) + issue3.update(due_date: Date.yesterday) + + visit project_issues_path(project, due_date: Issue::DueNextMonthAndPreviousTwoWeeks.name) + + page.within '.issues-holder' do + expect(page).not_to have_content('foo') + expect(page).not_to have_content('bar') + expect(page).to have_content('baz') + end + end + end + + describe 'sorting by milestone' do + before do + issue1.milestone = newer_due_milestone + issue1.save + issue2.milestone = later_due_milestone + issue2.save + end + + it 'sorts by milestone' do + visit project_issues_path(project, sort: sort_value_milestone) + + expect(first_issue).to include('foo') + expect(last_issue).to include('baz') + end + end + + describe 'combine filter and sort' do + let(:user2) { create(:user) } + + before do + issue1.assignees << user2 + issue1.save + issue2.assignees << user2 + issue2.save + end + + it 'sorts with a filter applied' do + visit project_issues_path(project, sort: sort_value_created_date, assignee_id: user2.id) + + expect(first_issue).to include('foo') + expect(last_issue).to include('bar') + expect(page).not_to have_content('baz') + end + end end diff --git a/spec/features/issues_spec.rb b/spec/features/issues_spec.rb deleted file mode 100644 index ef9daf70b0c2ba972cb64af682014e494af5f061..0000000000000000000000000000000000000000 --- a/spec/features/issues_spec.rb +++ /dev/null @@ -1,828 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -describe 'Issues' do - include DropzoneHelper - include IssueHelpers - include SortingHelper - - let(:user) { create(:user) } - let(:project) { create(:project, :public) } - - shared_examples_for 'empty state with filters' do - it 'user sees empty state with filters' do - create(:issue, author: user, project: project) - - visit project_issues_path(project, milestone_title: "1.0") - - expect(page).to have_content('Sorry, your filter produced no results') - expect(page).to have_content('To widen your search, change or remove filters above') - end - end - - describe 'while user is signed out' do - describe 'empty state' do - it 'user sees empty state' do - visit project_issues_path(project) - - expect(page).to have_content('Register / Sign In') - expect(page).to have_content('The Issue Tracker is the place to add things that need to be improved or solved in a project.') - expect(page).to have_content('You can register or sign in to create issues for this project.') - end - - it_behaves_like 'empty state with filters' - end - end - - describe 'while user is signed in' do - before do - sign_in(user) - user2 = create(:user) - - project.add_developer(user) - project.add_developer(user2) - end - - describe 'empty state' do - it 'user sees empty state' do - visit project_issues_path(project) - - expect(page).to have_content('The Issue Tracker is the place to add things that need to be improved or solved in a project') - expect(page).to have_content('Issues can be bugs, tasks or ideas to be discussed. Also, issues are searchable and filterable.') - expect(page).to have_content('New issue') - end - - it_behaves_like 'empty state with filters' - end - - describe 'Edit issue' do - let!(:issue) do - create(:issue, - author: user, - assignees: [user], - project: project) - end - - before do - visit edit_project_issue_path(project, issue) - find('.js-zen-enter').click - end - - it 'opens new issue popup' do - expect(page).to have_content("Issue ##{issue.iid}") - end - end - - describe 'Editing issue assignee' do - let!(:issue) do - create(:issue, - author: user, - assignees: [user], - project: project) - end - - it 'allows user to select unassigned', :js do - visit edit_project_issue_path(project, issue) - - expect(page).to have_content "Assignee #{user.name}" - - first('.js-user-search').click - click_link 'Unassigned' - - click_button 'Save changes' - - page.within('.assignee') do - expect(page).to have_content 'None - assign yourself' - end - - expect(issue.reload.assignees).to be_empty - end - end - - describe 'due date', :js do - context 'on new form' do - before do - visit new_project_issue_path(project) - end - - it 'saves with due date' do - date = Date.today.at_beginning_of_month - - fill_in 'issue_title', with: 'bug 345' - fill_in 'issue_description', with: 'bug description' - find('#issuable-due-date').click - - page.within '.pika-single' do - click_button date.day - end - - expect(find('#issuable-due-date').value).to eq date.to_s - - click_button 'Submit issue' - - page.within '.issuable-sidebar' do - expect(page).to have_content date.to_s(:medium) - end - end - end - - context 'on edit form' do - let(:issue) { create(:issue, author: user, project: project, due_date: Date.today.at_beginning_of_month.to_s) } - - before do - visit edit_project_issue_path(project, issue) - end - - it 'saves with due date' do - date = Date.today.at_beginning_of_month - - expect(find('#issuable-due-date').value).to eq date.to_s - - date = date.tomorrow - - fill_in 'issue_title', with: 'bug 345' - fill_in 'issue_description', with: 'bug description' - find('#issuable-due-date').click - - page.within '.pika-single' do - click_button date.day - end - - expect(find('#issuable-due-date').value).to eq date.to_s - - click_button 'Save changes' - - page.within '.issuable-sidebar' do - expect(page).to have_content date.to_s(:medium) - end - end - - it 'warns about version conflict' do - issue.update(title: "New title") - - fill_in 'issue_title', with: 'bug 345' - fill_in 'issue_description', with: 'bug description' - - click_button 'Save changes' - - expect(page).to have_content 'Someone edited the issue the same time you did' - end - end - end - - describe 'Issue info' do - it 'links to current issue in breadcrubs' do - issue = create(:issue, project: project) - - visit project_issue_path(project, issue) - - expect(find('.breadcrumbs-sub-title a')[:href]).to end_with(issue_path(issue)) - end - - it 'excludes award_emoji from comment count' do - issue = create(:issue, author: user, assignees: [user], project: project, title: 'foobar') - create(:award_emoji, awardable: issue) - - visit project_issues_path(project, assignee_id: user.id) - - expect(page).to have_content 'foobar' - expect(page.all('.no-comments').first.text).to eq "0" - end - end - - describe 'Filter issue' do - before do - %w(foobar barbaz gitlab).each do |title| - create(:issue, - author: user, - assignees: [user], - project: project, - title: title) - end - - @issue = Issue.find_by(title: 'foobar') - @issue.milestone = create(:milestone, project: project) - @issue.assignees = [] - @issue.save - end - - let(:issue) { @issue } - - it 'allows filtering by issues with no specified assignee' do - visit project_issues_path(project, assignee_id: IssuableFinder::FILTER_NONE) - - expect(page).to have_content 'foobar' - expect(page).not_to have_content 'barbaz' - expect(page).not_to have_content 'gitlab' - end - - it 'allows filtering by a specified assignee' do - visit project_issues_path(project, assignee_id: user.id) - - expect(page).not_to have_content 'foobar' - expect(page).to have_content 'barbaz' - expect(page).to have_content 'gitlab' - end - end - - describe 'filter issue' do - titles = %w[foo bar baz] - titles.each_with_index do |title, index| - let!(title.to_sym) do - create(:issue, title: title, - project: project, - created_at: Time.now - (index * 60)) - end - end - let(:newer_due_milestone) { create(:milestone, project: project, due_date: '2013-12-11') } - let(:later_due_milestone) { create(:milestone, project: project, due_date: '2013-12-12') } - - it 'sorts by newest' do - visit project_issues_path(project, sort: sort_value_created_date) - - expect(first_issue).to include('foo') - expect(last_issue).to include('baz') - end - - it 'sorts by most recently updated' do - baz.updated_at = Time.now + 100 - baz.save - visit project_issues_path(project, sort: sort_value_recently_updated) - - expect(first_issue).to include('baz') - end - - describe 'sorting by due date' do - before do - foo.update(due_date: 1.day.from_now) - bar.update(due_date: 6.days.from_now) - end - - it 'sorts by due date' do - visit project_issues_path(project, sort: sort_value_due_date) - - expect(first_issue).to include('foo') - end - - it 'sorts by due date by excluding nil due dates' do - bar.update(due_date: nil) - - visit project_issues_path(project, sort: sort_value_due_date) - - expect(first_issue).to include('foo') - end - - context 'with a filter on labels' do - let(:label) { create(:label, project: project) } - - before do - create(:label_link, label: label, target: foo) - end - - it 'sorts by least recently due date by excluding nil due dates' do - bar.update(due_date: nil) - - visit project_issues_path(project, label_names: [label.name], sort: sort_value_due_date_later) - - expect(first_issue).to include('foo') - end - end - end - - describe 'filtering by due date' do - before do - foo.update(due_date: 1.day.from_now) - bar.update(due_date: 6.days.from_now) - end - - it 'filters by none' do - visit project_issues_path(project, due_date: Issue::NoDueDate.name) - - page.within '.issues-holder' do - expect(page).not_to have_content('foo') - expect(page).not_to have_content('bar') - expect(page).to have_content('baz') - end - end - - it 'filters by any' do - visit project_issues_path(project, due_date: Issue::AnyDueDate.name) - - page.within '.issues-holder' do - expect(page).to have_content('foo') - expect(page).to have_content('bar') - expect(page).to have_content('baz') - end - end - - it 'filters by due this week' do - foo.update(due_date: Date.today.beginning_of_week + 2.days) - bar.update(due_date: Date.today.end_of_week) - baz.update(due_date: Date.today - 8.days) - - visit project_issues_path(project, due_date: Issue::DueThisWeek.name) - - page.within '.issues-holder' do - expect(page).to have_content('foo') - expect(page).to have_content('bar') - expect(page).not_to have_content('baz') - end - end - - it 'filters by due this month' do - foo.update(due_date: Date.today.beginning_of_month + 2.days) - bar.update(due_date: Date.today.end_of_month) - baz.update(due_date: Date.today - 50.days) - - visit project_issues_path(project, due_date: Issue::DueThisMonth.name) - - page.within '.issues-holder' do - expect(page).to have_content('foo') - expect(page).to have_content('bar') - expect(page).not_to have_content('baz') - end - end - - it 'filters by overdue' do - foo.update(due_date: Date.today + 2.days) - bar.update(due_date: Date.today + 20.days) - baz.update(due_date: Date.yesterday) - - visit project_issues_path(project, due_date: Issue::Overdue.name) - - page.within '.issues-holder' do - expect(page).not_to have_content('foo') - expect(page).not_to have_content('bar') - expect(page).to have_content('baz') - end - end - - it 'filters by due next month and previous two weeks' do - foo.update(due_date: Date.today - 4.weeks) - bar.update(due_date: (Date.today + 2.months).beginning_of_month) - baz.update(due_date: Date.yesterday) - - visit project_issues_path(project, due_date: Issue::DueNextMonthAndPreviousTwoWeeks.name) - - page.within '.issues-holder' do - expect(page).not_to have_content('foo') - expect(page).not_to have_content('bar') - expect(page).to have_content('baz') - end - end - end - - describe 'sorting by milestone' do - before do - foo.milestone = newer_due_milestone - foo.save - bar.milestone = later_due_milestone - bar.save - end - - it 'sorts by milestone' do - visit project_issues_path(project, sort: sort_value_milestone) - - expect(first_issue).to include('foo') - expect(last_issue).to include('baz') - end - end - - describe 'combine filter and sort' do - let(:user2) { create(:user) } - - before do - foo.assignees << user2 - foo.save - bar.assignees << user2 - bar.save - end - - it 'sorts with a filter applied' do - visit project_issues_path(project, sort: sort_value_created_date, assignee_id: user2.id) - - expect(first_issue).to include('foo') - expect(last_issue).to include('bar') - expect(page).not_to have_content('baz') - end - end - end - - describe 'when I want to reset my incoming email token' do - let(:project1) { create(:project, namespace: user.namespace) } - let!(:issue) { create(:issue, project: project1) } - - before do - stub_incoming_email_setting(enabled: true, address: "p+%{key}@gl.ab") - project1.add_maintainer(user) - visit namespace_project_issues_path(user.namespace, project1) - end - - it 'changes incoming email address token', :js do - find('.issuable-email-modal-btn').click - previous_token = find('input#issuable_email').value - find('.incoming-email-token-reset').click - - wait_for_requests - - expect(page).to have_no_field('issuable_email', with: previous_token) - new_token = project1.new_issuable_address(user.reload, 'issue') - expect(page).to have_field( - 'issuable_email', - with: new_token - ) - end - end - - describe 'update labels from issue#show', :js do - let(:issue) { create(:issue, project: project, author: user, assignees: [user]) } - let!(:label) { create(:label, project: project) } - - before do - visit project_issue_path(project, issue) - end - - it 'will not send ajax request when no data is changed' do - page.within '.labels' do - click_link 'Edit' - - find('.dropdown-menu-close', match: :first).click - - expect(page).not_to have_selector('.block-loading') - end - end - end - - describe 'update assignee from issue#show' do - let(:issue) { create(:issue, project: project, author: user, assignees: [user]) } - - context 'by authorized user' do - it 'allows user to select unassigned', :js do - visit project_issue_path(project, issue) - - page.within('.assignee') do - expect(page).to have_content "#{user.name}" - - click_link 'Edit' - click_link 'Unassigned' - first('.title').click - expect(page).to have_content 'None' - end - - wait_for_requests - - expect(issue.reload.assignees).to be_empty - end - - it 'allows user to select an assignee', :js do - issue2 = create(:issue, project: project, author: user) - visit project_issue_path(project, issue2) - - page.within('.assignee') do - expect(page).to have_content "None" - end - - page.within '.assignee' do - click_link 'Edit' - end - - page.within '.dropdown-menu-user' do - click_link user.name - end - - page.within('.assignee') do - expect(page).to have_content user.name - end - end - - it 'allows user to unselect themselves', :js do - issue2 = create(:issue, project: project, author: user) - - visit project_issue_path(project, issue2) - - def close_dropdown_menu_if_visible - find('.dropdown-menu-toggle', visible: :all).tap do |toggle| - toggle.click if toggle.visible? - end - end - - page.within '.assignee' do - click_link 'Edit' - click_link user.name - - close_dropdown_menu_if_visible - - page.within '.value .author' do - expect(page).to have_content user.name - end - - click_link 'Edit' - click_link user.name - - close_dropdown_menu_if_visible - - page.within '.value .assign-yourself' do - expect(page).to have_content "None" - end - end - end - end - - context 'by unauthorized user' do - let(:guest) { create(:user) } - - before do - project.add_guest(guest) - end - - it 'shows assignee text', :js do - sign_out(:user) - sign_in(guest) - - visit project_issue_path(project, issue) - expect(page).to have_content issue.assignees.first.name - end - end - end - - describe 'update milestone from issue#show' do - let!(:issue) { create(:issue, project: project, author: user) } - let!(:milestone) { create(:milestone, project: project) } - - context 'by authorized user' do - it 'allows user to select unassigned', :js do - visit project_issue_path(project, issue) - - page.within('.milestone') do - expect(page).to have_content "None" - end - - find('.block.milestone .edit-link').click - sleep 2 # wait for ajax stuff to complete - first('.dropdown-content li').click - sleep 2 - page.within('.milestone') do - expect(page).to have_content 'None' - end - - expect(issue.reload.milestone).to be_nil - end - - it 'allows user to de-select milestone', :js do - visit project_issue_path(project, issue) - - page.within('.milestone') do - click_link 'Edit' - click_link milestone.title - - page.within '.value' do - expect(page).to have_content milestone.title - end - - click_link 'Edit' - click_link milestone.title - - page.within '.value' do - expect(page).to have_content 'None' - end - end - end - end - - context 'by unauthorized user' do - let(:guest) { create(:user) } - - before do - project.add_guest(guest) - issue.milestone = milestone - issue.save - end - - it 'shows milestone text', :js do - sign_out(:user) - sign_in(guest) - - visit project_issue_path(project, issue) - expect(page).to have_content milestone.title - end - end - end - - describe 'new issue' do - let!(:issue) { create(:issue, project: project) } - - context 'by unauthenticated user' do - before do - sign_out(:user) - end - - it 'redirects to signin then back to new issue after signin' do - visit project_issues_path(project) - - page.within '.nav-controls' do - click_link 'New issue' - end - - expect(current_path).to eq new_user_session_path - - gitlab_sign_in(create(:user)) - - expect(current_path).to eq new_project_issue_path(project) - end - end - - it 'clears local storage after creating a new issue', :js do - 2.times do - visit new_project_issue_path(project) - wait_for_requests - - expect(page).to have_field('Title', with: '') - - fill_in 'issue_title', with: 'bug 345' - fill_in 'issue_description', with: 'bug description' - - click_button 'Submit issue' - end - end - - context 'dropzone upload file', :js do - before do - visit new_project_issue_path(project) - end - - it 'uploads file when dragging into textarea' do - dropzone_file Rails.root.join('spec', 'fixtures', 'banana_sample.gif') - - expect(page.find_field("issue_description").value).to have_content 'banana_sample' - end - - it "doesn't add double newline to end of a single attachment markdown" do - dropzone_file Rails.root.join('spec', 'fixtures', 'banana_sample.gif') - - expect(page.find_field("issue_description").value).not_to match /\n\n$/ - end - - it "cancels a file upload correctly" do - slow_requests do - dropzone_file([Rails.root.join('spec', 'fixtures', 'dk.png')], 0, false) - - click_button 'Cancel' - end - - expect(page).to have_button('Attach a file') - expect(page).not_to have_button('Cancel') - expect(page).not_to have_selector('.uploading-progress-container', visible: true) - end - end - - context 'form filled by URL parameters' do - let(:project) { create(:project, :public, :repository) } - - before do - project.repository.create_file( - user, - '.gitlab/issue_templates/bug.md', - 'this is a test "bug" template', - message: 'added issue template', - branch_name: 'master') - - visit new_project_issue_path(project, issuable_template: 'bug') - end - - it 'fills in template' do - expect(find('.js-issuable-selector .dropdown-toggle-text')).to have_content('bug') - end - end - - context 'suggestions', :js do - it 'displays list of related issues' do - create(:issue, project: project, title: 'test issue') - - visit new_project_issue_path(project) - - fill_in 'issue_title', with: issue.title - - expect(page).to have_selector('.suggestion-item', count: 1) - end - end - end - - describe 'new issue by email' do - shared_examples 'show the email in the modal' do - let(:issue) { create(:issue, project: project) } - - before do - project.issues << issue - stub_incoming_email_setting(enabled: true, address: "p+%{key}@gl.ab") - - visit project_issues_path(project) - click_button('Email a new issue') - end - - it 'click the button to show modal for the new email' do - page.within '#issuable-email-modal' do - email = project.new_issuable_address(user, 'issue') - - expect(page).to have_selector("input[value='#{email}']") - end - end - end - - context 'with existing issues' do - let!(:issue) { create(:issue, project: project, author: user) } - - it_behaves_like 'show the email in the modal' - end - - context 'without existing issues' do - it_behaves_like 'show the email in the modal' - end - end - - describe 'due date' do - context 'update due on issue#show', :js do - let(:issue) { create(:issue, project: project, author: user, assignees: [user]) } - - before do - visit project_issue_path(project, issue) - end - - it 'adds due date to issue' do - date = Date.today.at_beginning_of_month + 2.days - - page.within '.due_date' do - click_link 'Edit' - - page.within '.pika-single' do - click_button date.day - end - - wait_for_requests - - expect(find('.value').text).to have_content date.strftime('%b %-d, %Y') - end - end - - it 'removes due date from issue' do - date = Date.today.at_beginning_of_month + 2.days - - page.within '.due_date' do - click_link 'Edit' - - page.within '.pika-single' do - click_button date.day - end - - wait_for_requests - - expect(page).to have_no_content 'None' - - click_link 'remove due date' - expect(page).to have_content 'None' - end - end - end - end - - describe 'title issue#show', :js do - it 'updates the title', :js do - issue = create(:issue, author: user, assignees: [user], project: project, title: 'new title') - - visit project_issue_path(project, issue) - - expect(page).to have_text("new title") - - issue.update(title: "updated title") - - wait_for_requests - expect(page).to have_text("updated title") - end - end - - describe 'confidential issue#show', :js do - it 'shows confidential sibebar information as confidential and can be turned off' do - issue = create(:issue, :confidential, project: project) - - visit project_issue_path(project, issue) - - expect(page).to have_css('.issuable-note-warning') - expect(find('.issuable-sidebar-item.confidentiality')).to have_css('.is-active') - expect(find('.issuable-sidebar-item.confidentiality')).not_to have_css('.not-active') - - find('.confidential-edit').click - expect(page).to have_css('.sidebar-item-warning-message') - - within('.sidebar-item-warning-message') do - find('.btn-close').click - end - - wait_for_requests - - visit project_issue_path(project, issue) - - expect(page).not_to have_css('.is-active') - end - end - end -end diff --git a/spec/features/labels_hierarchy_spec.rb b/spec/features/labels_hierarchy_spec.rb index b7a45905845b3a12f691d84d916baa1caff72651..c1a2e22a0c2e38196990fee11329ad69610cd318 100644 --- a/spec/features/labels_hierarchy_spec.rb +++ b/spec/features/labels_hierarchy_spec.rb @@ -70,7 +70,7 @@ describe 'Labels Hierarchy', :js do end it 'does not filter by descendant group labels' do - filtered_search.set("label:") + filtered_search.set("label=") wait_for_requests @@ -134,7 +134,7 @@ describe 'Labels Hierarchy', :js do end it 'does not filter by descendant group project labels' do - filtered_search.set("label:") + filtered_search.set("label=") wait_for_requests @@ -227,7 +227,7 @@ describe 'Labels Hierarchy', :js do it_behaves_like 'filtering by ancestor labels for projects' it 'does not filter by descendant group labels' do - filtered_search.set("label:") + filtered_search.set("label=") wait_for_requests diff --git a/spec/features/markdown/markdown_spec.rb b/spec/features/markdown/markdown_spec.rb index a45fa67ce9e94e59cd2497871e987501efa68f96..9ebd85acb81ca85058ab402149a189c9418e19b1 100644 --- a/spec/features/markdown/markdown_spec.rb +++ b/spec/features/markdown/markdown_spec.rb @@ -208,6 +208,8 @@ describe 'GitLab Markdown', :aggregate_failures do @group = @feat.group end + let(:project) { @feat.project } # Shadow this so matchers can use it + context 'default pipeline' do before do @html = markdown(@feat.raw_markdown) @@ -216,8 +218,12 @@ describe 'GitLab Markdown', :aggregate_failures do it_behaves_like 'all pipelines' it 'includes custom filters' do - aggregate_failures 'RelativeLinkFilter' do - expect(doc).to parse_relative_links + aggregate_failures 'UploadLinkFilter' do + expect(doc).to parse_upload_links + end + + aggregate_failures 'RepositoryLinkFilter' do + expect(doc).to parse_repository_links end aggregate_failures 'EmojiFilter' do @@ -277,8 +283,12 @@ describe 'GitLab Markdown', :aggregate_failures do it_behaves_like 'all pipelines' it 'includes custom filters' do - aggregate_failures 'RelativeLinkFilter' do - expect(doc).not_to parse_relative_links + aggregate_failures 'UploadLinkFilter' do + expect(doc).to parse_upload_links + end + + aggregate_failures 'RepositoryLinkFilter' do + expect(doc).not_to parse_repository_links end aggregate_failures 'EmojiFilter' do diff --git a/spec/features/merge_request/maintainer_edits_fork_spec.rb b/spec/features/merge_request/maintainer_edits_fork_spec.rb index 4e161d530d3f6e85479a9a2c32dce0ead15522a7..4f2c5fc73d8d0c77fcd80dd47207fa6b9789f5e0 100644 --- a/spec/features/merge_request/maintainer_edits_fork_spec.rb +++ b/spec/features/merge_request/maintainer_edits_fork_spec.rb @@ -32,8 +32,6 @@ describe 'a maintainer edits files on a source-branch of an MR from a fork', :js wait_for_requests end - it_behaves_like 'rendering a single diff version' - it 'mentions commits will go to the source branch' do expect(page).to have_content('Your changes can be committed to fix because a merge request is open.') end diff --git a/spec/features/merge_request/user_comments_on_diff_spec.rb b/spec/features/merge_request/user_comments_on_diff_spec.rb index 6a23b6cdf60f7b102ebac52a23b20c8c88846019..19b8a7f74b73d3b025c33a77f7249df96d252623 100644 --- a/spec/features/merge_request/user_comments_on_diff_spec.rb +++ b/spec/features/merge_request/user_comments_on_diff_spec.rb @@ -13,15 +13,12 @@ describe 'User comments on a diff', :js do let(:user) { create(:user) } before do - stub_feature_flags(single_mr_diff_view: false) project.add_maintainer(user) sign_in(user) visit(diffs_project_merge_request_path(project, merge_request)) end - it_behaves_like 'rendering a single diff version' - context 'when viewing comments' do context 'when toggling inline comments' do context 'in a single file' do diff --git a/spec/features/merge_request/user_creates_image_diff_notes_spec.rb b/spec/features/merge_request/user_creates_image_diff_notes_spec.rb index e6634a8ff39ae2cceb4db94c3e352c9ba17db308..e0724a04ea34814c93da76989b456c149842715a 100644 --- a/spec/features/merge_request/user_creates_image_diff_notes_spec.rb +++ b/spec/features/merge_request/user_creates_image_diff_notes_spec.rb @@ -9,7 +9,6 @@ describe 'Merge request > User creates image diff notes', :js do let(:user) { project.creator } before do - stub_feature_flags(single_mr_diff_view: false) sign_in(user) # Stub helper to return any blob file as image from public app folder. @@ -18,8 +17,6 @@ describe 'Merge request > User creates image diff notes', :js do allow_any_instance_of(DiffHelper).to receive(:diff_file_old_blob_raw_url).and_return('/favicon.png') end - it_behaves_like 'rendering a single diff version' - context 'create commit diff notes' do commit_id = '2f63565e7aac07bcdadb654e253078b727143ec4' diff --git a/spec/features/merge_request/user_expands_diff_spec.rb b/spec/features/merge_request/user_expands_diff_spec.rb index 9b04027146862040edb94f826ea364666501f6c0..9bce5264817e96a0027b460e931da8297c3427d4 100644 --- a/spec/features/merge_request/user_expands_diff_spec.rb +++ b/spec/features/merge_request/user_expands_diff_spec.rb @@ -7,7 +7,6 @@ describe 'User expands diff', :js do let(:merge_request) { create(:merge_request, source_branch: 'expand-collapse-files', source_project: project, target_project: project) } before do - stub_feature_flags(single_mr_diff_view: false) stub_feature_flags(diffs_batch_load: false) allow(Gitlab::Git::Diff).to receive(:size_limit).and_return(100.kilobytes) @@ -18,8 +17,6 @@ describe 'User expands diff', :js do wait_for_requests end - it_behaves_like 'rendering a single diff version' - it 'allows user to expand diff' do page.within find('[id="6eb14e00385d2fb284765eb1cd8d420d33d63fc9"]') do click_link 'Click to expand it.' diff --git a/spec/features/merge_request/user_posts_diff_notes_spec.rb b/spec/features/merge_request/user_posts_diff_notes_spec.rb index 6328c0a51335c2264fae748d50b1d08a2dab9d37..8b16760606ceadf5bd559d1198a7cff2bc2ac75f 100644 --- a/spec/features/merge_request/user_posts_diff_notes_spec.rb +++ b/spec/features/merge_request/user_posts_diff_notes_spec.rb @@ -14,15 +14,12 @@ describe 'Merge request > User posts diff notes', :js do let(:test_note_comment) { 'this is a test note!' } before do - stub_feature_flags(single_mr_diff_view: false) set_cookie('sidebar_collapsed', 'true') project.add_developer(user) sign_in(user) end - it_behaves_like 'rendering a single diff version' - context 'when hovering over a parallel view diff file' do before do visit diffs_project_merge_request_path(project, merge_request, view: 'parallel') diff --git a/spec/features/merge_request/user_posts_notes_spec.rb b/spec/features/merge_request/user_posts_notes_spec.rb index c0655581b18d73e14d5448adef24abcd85aa8023..f24e7090605e284bcba57bb9d8064cfebc338e7e 100644 --- a/spec/features/merge_request/user_posts_notes_spec.rb +++ b/spec/features/merge_request/user_posts_notes_spec.rb @@ -165,9 +165,9 @@ describe 'Merge request > User posts notes', :js do find('.js-note-edit').click page.within('.current-note-edit-form') do - expect(find('#note_note').value).to eq('This is the new content') + expect(find('#note_note').value).to include('This is the new content') first('.js-md').click - expect(find('#note_note').value).to eq('This is the new content****') + expect(find('#note_note').value).to include('This is the new content****') end end diff --git a/spec/features/merge_request/user_resolves_conflicts_spec.rb b/spec/features/merge_request/user_resolves_conflicts_spec.rb index f0949fefa3b25a393a0d66a01ad958c278e8d9db..ce85e81868da09a8d6ea9c6891c5fef1c2859c1c 100644 --- a/spec/features/merge_request/user_resolves_conflicts_spec.rb +++ b/spec/features/merge_request/user_resolves_conflicts_spec.rb @@ -9,7 +9,6 @@ describe 'Merge request > User resolves conflicts', :js do before do # In order to have the diffs collapsed, we need to disable the increase feature stub_feature_flags(gitlab_git_diff_size_limit_increase: false) - stub_feature_flags(single_mr_diff_view: false) end def create_merge_request(source_branch) @@ -18,8 +17,6 @@ describe 'Merge request > User resolves conflicts', :js do end end - it_behaves_like 'rendering a single diff version' - shared_examples 'conflicts are resolved in Interactive mode' do it 'conflicts are resolved in Interactive mode' do within find('.files-wrapper .diff-file', text: 'files/ruby/popen.rb') do 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 9cbea8a84661797ba78d3d4c4f7edba8ee298bdb..eb86b1e33af4555246e8798cc6d950d9a4cd9f73 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 @@ -20,12 +20,9 @@ describe 'Merge request > User resolves diff notes and threads', :js do end before do - stub_feature_flags(single_mr_diff_view: false) stub_feature_flags(diffs_batch_load: false) end - it_behaves_like 'rendering a single diff version' - context 'no threads' do before do project.add_maintainer(user) diff --git a/spec/features/merge_request/user_sees_avatar_on_diff_notes_spec.rb b/spec/features/merge_request/user_sees_avatar_on_diff_notes_spec.rb index 70afe056c644029be14c0f676e773b3ef08ed8e1..3e77b9e75d62d924bcb2069f8dad34815c7ece30 100644 --- a/spec/features/merge_request/user_sees_avatar_on_diff_notes_spec.rb +++ b/spec/features/merge_request/user_sees_avatar_on_diff_notes_spec.rb @@ -21,7 +21,6 @@ describe 'Merge request > User sees avatars on diff notes', :js do let!(:note) { create(:diff_note_on_merge_request, project: project, noteable: merge_request, position: position) } before do - stub_feature_flags(single_mr_diff_view: false) stub_feature_flags(diffs_batch_load: false) project.add_maintainer(user) sign_in user @@ -29,8 +28,6 @@ describe 'Merge request > User sees avatars on diff notes', :js do set_cookie('sidebar_collapsed', 'true') end - it_behaves_like 'rendering a single diff version' - context 'discussion tab' do before do visit project_merge_request_path(project, merge_request) 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 3743ef0f25de744629a9ff298436774a61c0f83b..99c9e9dc5016159ce327b856354e543d5428a2b6 100644 --- a/spec/features/merge_request/user_sees_deployment_widget_spec.rb +++ b/spec/features/merge_request/user_sees_deployment_widget_spec.rb @@ -33,10 +33,10 @@ describe 'Merge request > User sees deployment widget', :js do end context 'when a user created a new merge request with the same SHA' do - let(:pipeline2) { create(:ci_pipeline, sha: sha, project: project, ref: 'new-patch-1') } + let(:pipeline2) { create(:ci_pipeline, sha: sha, project: project, ref: 'video') } 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) } + let!(:deployment2) { create(:deployment, environment: environment2, sha: sha, ref: 'video', deployable: build2) } it 'displays one environment which is related to the pipeline' do visit project_merge_request_path(project, merge_request) diff --git a/spec/features/merge_request/user_sees_diff_spec.rb b/spec/features/merge_request/user_sees_diff_spec.rb index de142344c2666a95838c6e4c748184d95b5d7e08..2d91d09a486b216084642f241cddff3ea51febd4 100644 --- a/spec/features/merge_request/user_sees_diff_spec.rb +++ b/spec/features/merge_request/user_sees_diff_spec.rb @@ -10,12 +10,9 @@ describe 'Merge request > User sees diff', :js do let(:merge_request) { create(:merge_request, source_project: project) } before do - stub_feature_flags(single_mr_diff_view: false) stub_feature_flags(diffs_batch_load: false) end - it_behaves_like 'rendering a single diff version' - context 'when linking to note' do describe 'with unresolved note' do let(:note) { create :diff_note_on_merge_request, project: project, noteable: merge_request } diff --git a/spec/features/merge_request/user_sees_mr_with_deleted_source_branch_spec.rb b/spec/features/merge_request/user_sees_mr_with_deleted_source_branch_spec.rb index e28d2ca553643334b60c97ca034261bd90950bd0..59e5f5c847db72e9912d9e01052050ed6bc79314 100644 --- a/spec/features/merge_request/user_sees_mr_with_deleted_source_branch_spec.rb +++ b/spec/features/merge_request/user_sees_mr_with_deleted_source_branch_spec.rb @@ -11,14 +11,11 @@ describe 'Merge request > User sees MR with deleted source branch', :js do let(:user) { project.creator } before do - stub_feature_flags(single_mr_diff_view: false) merge_request.update!(source_branch: 'this-branch-does-not-exist') sign_in(user) visit project_merge_request_path(project, merge_request) end - it_behaves_like 'rendering a single diff version' - it 'shows a message about missing source branch' do expect(page).to have_content('Source branch does not exist.') end diff --git a/spec/features/merge_request/user_sees_versions_spec.rb b/spec/features/merge_request/user_sees_versions_spec.rb index b3aef601c7bae4c9aacc19427b32693a94bc0803..cd62bab412a580ab15f0e604d24877f510181187 100644 --- a/spec/features/merge_request/user_sees_versions_spec.rb +++ b/spec/features/merge_request/user_sees_versions_spec.rb @@ -16,7 +16,6 @@ describe 'Merge request > User sees versions', :js do let!(:params) { {} } before do - stub_feature_flags(single_mr_diff_view: false) stub_feature_flags(diffs_batch_load: false) project.add_maintainer(user) @@ -24,8 +23,6 @@ describe 'Merge request > User sees versions', :js do visit diffs_project_merge_request_path(project, merge_request, params) end - it_behaves_like 'rendering a single diff version' - shared_examples 'allows commenting' do |file_id:, line_code:, comment:| it do diff_file_selector = ".diff-file[id='#{file_id}']" @@ -53,7 +50,7 @@ describe 'Merge request > User sees versions', :js do expect(page).to have_content 'latest version' end - expect(page).to have_content '8 Files' + expect(page).to have_content '8 files' end it_behaves_like 'allows commenting', @@ -87,7 +84,7 @@ describe 'Merge request > User sees versions', :js do end it 'shows comments that were last relevant at that version' do - expect(page).to have_content '5 Files' + expect(page).to have_content '5 files' position = Gitlab::Diff::Position.new( old_path: ".gitmodules", @@ -131,12 +128,10 @@ describe 'Merge request > User sees versions', :js do diff_id: merge_request_diff3.id, start_sha: '6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9' ) - expect(page).to have_content '4 Files' + expect(page).to have_content '4 files' - additions_content = page.find('.diff-stats.is-compare-versions-header .diff-stats-group svg.ic-file-addition') - .ancestor('.diff-stats-group').text - deletions_content = page.find('.diff-stats.is-compare-versions-header .diff-stats-group svg.ic-file-deletion') - .ancestor('.diff-stats-group').text + additions_content = page.find('.diff-stats.is-compare-versions-header .diff-stats-group .js-file-addition-line').text + deletions_content = page.find('.diff-stats.is-compare-versions-header .diff-stats-group .js-file-deletion-line').text expect(additions_content).to eq '15' expect(deletions_content).to eq '6' @@ -159,12 +154,10 @@ describe 'Merge request > User sees versions', :js do end it 'show diff between new and old version' do - additions_content = page.find('.diff-stats.is-compare-versions-header .diff-stats-group svg.ic-file-addition') - .ancestor('.diff-stats-group').text - deletions_content = page.find('.diff-stats.is-compare-versions-header .diff-stats-group svg.ic-file-deletion') - .ancestor('.diff-stats-group').text + additions_content = page.find('.diff-stats.is-compare-versions-header .diff-stats-group .js-file-addition-line').text + deletions_content = page.find('.diff-stats.is-compare-versions-header .diff-stats-group .js-file-deletion-line').text - expect(page).to have_content '4 Files' + expect(page).to have_content '4 files' expect(additions_content).to eq '15' expect(deletions_content).to eq '6' end @@ -174,7 +167,7 @@ describe 'Merge request > User sees versions', :js do page.within '.mr-version-dropdown' do expect(page).to have_content 'latest version' end - expect(page).to have_content '8 Files' + expect(page).to have_content '8 files' end it_behaves_like 'allows commenting', @@ -200,7 +193,7 @@ describe 'Merge request > User sees versions', :js do find('.btn-default').click click_link 'version 1' end - expect(page).to have_content '0 Files' + expect(page).to have_content '0 files' end end @@ -226,7 +219,7 @@ describe 'Merge request > User sees versions', :js do expect(page).to have_content 'version 1' end - expect(page).to have_content '0 Files' + expect(page).to have_content '0 files' end end diff --git a/spec/features/merge_request/user_suggests_changes_on_diff_spec.rb b/spec/features/merge_request/user_suggests_changes_on_diff_spec.rb index 7fe72e1bc8a141f2cf9a7a3a47bd1f20fb10fab1..95cb0a2dee38b90a08f127c43dc702c5e41c957c 100644 --- a/spec/features/merge_request/user_suggests_changes_on_diff_spec.rb +++ b/spec/features/merge_request/user_suggests_changes_on_diff_spec.rb @@ -25,15 +25,12 @@ describe 'User comments on a diff', :js do let(:user) { create(:user) } before do - stub_feature_flags(single_mr_diff_view: false) project.add_maintainer(user) sign_in(user) visit(diffs_project_merge_request_path(project, merge_request)) end - it_behaves_like 'rendering a single diff version' - context 'single suggestion note' do it 'hides suggestion popover' do click_diff_line(find("[id='#{sample_compare.changes[1][:line_code]}']")) @@ -97,8 +94,7 @@ describe 'User comments on a diff', :js do end context 'multiple suggestions in expanded lines' do - # Report issue: https://gitlab.com/gitlab-org/gitlab/issues/38277 - # Fix issue: https://gitlab.com/gitlab-org/gitlab/issues/39095 + # https://gitlab.com/gitlab-org/gitlab/issues/38277 it 'suggestions are appliable', :quarantine do diff_file = merge_request.diffs(paths: ['files/ruby/popen.rb']).diff_files.first hash = Digest::SHA1.hexdigest(diff_file.file_path) diff --git a/spec/features/merge_request/user_toggles_whitespace_changes_spec.rb b/spec/features/merge_request/user_toggles_whitespace_changes_spec.rb index 5e59bc87e68b84e82fa1e3eea3e1693a1cfa2123..4db067a4e41c9fc3f34b040d34896df54a741808 100644 --- a/spec/features/merge_request/user_toggles_whitespace_changes_spec.rb +++ b/spec/features/merge_request/user_toggles_whitespace_changes_spec.rb @@ -8,7 +8,6 @@ describe 'Merge request > User toggles whitespace changes', :js do let(:user) { project.creator } before do - stub_feature_flags(single_mr_diff_view: false) project.add_maintainer(user) sign_in(user) visit diffs_project_merge_request_path(project, merge_request) @@ -16,8 +15,6 @@ describe 'Merge request > User toggles whitespace changes', :js do find('.js-show-diff-settings').click end - it_behaves_like 'rendering a single diff version' - it 'has a button to toggle whitespace changes' do expect(page).to have_content 'Show whitespace changes' end diff --git a/spec/features/merge_request/user_views_diffs_spec.rb b/spec/features/merge_request/user_views_diffs_spec.rb index 313f438e23b611c74cb30ad2f0ada0c53af94eb2..e0e4058dd47056288869aef9250189c98766330a 100644 --- a/spec/features/merge_request/user_views_diffs_spec.rb +++ b/spec/features/merge_request/user_views_diffs_spec.rb @@ -9,7 +9,6 @@ describe 'User views diffs', :js do let(:project) { create(:project, :public, :repository) } before do - stub_feature_flags(single_mr_diff_view: false) stub_feature_flags(diffs_batch_load: false) visit(diffs_project_merge_request_path(project, merge_request)) @@ -18,8 +17,6 @@ describe 'User views diffs', :js do find('.js-toggle-tree-list').click end - it_behaves_like 'rendering a single diff version' - shared_examples 'unfold diffs' do it 'unfolds diffs upwards' do first('.js-unfold').click diff --git a/spec/features/merge_requests/filters_generic_behavior_spec.rb b/spec/features/merge_requests/filters_generic_behavior_spec.rb index 58aad1b7e91c4fd865bd27e90661b9cd6fe7c92c..c3400acae4f7b331cf2027918f3285b7f05562cf 100644 --- a/spec/features/merge_requests/filters_generic_behavior_spec.rb +++ b/spec/features/merge_requests/filters_generic_behavior_spec.rb @@ -23,7 +23,7 @@ describe 'Merge Requests > Filters generic behavior', :js do context 'when filtered by a label' do before do - input_filtered_search('label:~bug') + input_filtered_search('label=~bug') end describe 'state tabs' do diff --git a/spec/features/merge_requests/user_filters_by_assignees_spec.rb b/spec/features/merge_requests/user_filters_by_assignees_spec.rb index 00bd8455ae112fc7be7ac22617b047aec54b6802..3abee3b656a3f7766c248f339da5ba292639efc7 100644 --- a/spec/features/merge_requests/user_filters_by_assignees_spec.rb +++ b/spec/features/merge_requests/user_filters_by_assignees_spec.rb @@ -18,7 +18,7 @@ describe 'Merge Requests > User filters by assignees', :js do context 'filtering by assignee:none' do it 'applies the filter' do - input_filtered_search('assignee:none') + input_filtered_search('assignee=none') expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1) expect(page).not_to have_content 'Bugfix1' @@ -26,9 +26,9 @@ describe 'Merge Requests > User filters by assignees', :js do end end - context 'filtering by assignee:@username' do + context 'filtering by assignee=@username' do it 'applies the filter' do - input_filtered_search("assignee:@#{user.username}") + input_filtered_search("assignee=@#{user.username}") expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1) expect(page).to have_content 'Bugfix1' diff --git a/spec/features/merge_requests/user_filters_by_labels_spec.rb b/spec/features/merge_requests/user_filters_by_labels_spec.rb index fd2b4b23f96dc78bea76e4f3cf25d649a067050b..7a80ebe9be30e0d9b908f750da60ee7e20c6f6f4 100644 --- a/spec/features/merge_requests/user_filters_by_labels_spec.rb +++ b/spec/features/merge_requests/user_filters_by_labels_spec.rb @@ -22,7 +22,7 @@ describe 'Merge Requests > User filters by labels', :js do context 'filtering by label:none' do it 'applies the filter' do - input_filtered_search('label:none') + input_filtered_search('label=none') expect(page).to have_issuable_counts(open: 0, closed: 0, all: 0) expect(page).not_to have_content 'Bugfix1' @@ -32,7 +32,7 @@ describe 'Merge Requests > User filters by labels', :js do context 'filtering by label:~enhancement' do it 'applies the filter' do - input_filtered_search('label:~enhancement') + input_filtered_search('label=~enhancement') expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1) expect(page).to have_content 'Bugfix2' @@ -42,7 +42,7 @@ describe 'Merge Requests > User filters by labels', :js do context 'filtering by label:~enhancement and label:~bug' do it 'applies the filters' do - input_filtered_search('label:~bug label:~enhancement') + input_filtered_search('label=~bug label=~enhancement') expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1) expect(page).to have_content 'Bugfix2' diff --git a/spec/features/merge_requests/user_filters_by_milestones_spec.rb b/spec/features/merge_requests/user_filters_by_milestones_spec.rb index e0ee69d7a5b1f4486ba9dac61c5bb462edce8408..8cb686e191e88ba7da5aba541f8f0055aeb6634e 100644 --- a/spec/features/merge_requests/user_filters_by_milestones_spec.rb +++ b/spec/features/merge_requests/user_filters_by_milestones_spec.rb @@ -18,14 +18,14 @@ describe 'Merge Requests > User filters by milestones', :js do end it 'filters by no milestone' do - input_filtered_search('milestone:none') + input_filtered_search('milestone=none') expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1) expect(page).to have_css('.merge-request', count: 1) end it 'filters by a specific milestone' do - input_filtered_search("milestone:%'#{milestone.title}'") + input_filtered_search("milestone=%'#{milestone.title}'") expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1) expect(page).to have_css('.merge-request', count: 1) @@ -33,7 +33,7 @@ describe 'Merge Requests > User filters by milestones', :js do describe 'filters by upcoming milestone' do it 'does not show merge requests with no expiry' do - input_filtered_search('milestone:upcoming') + input_filtered_search('milestone=upcoming') expect(page).to have_issuable_counts(open: 0, closed: 0, all: 0) expect(page).to have_css('.merge-request', count: 0) @@ -43,7 +43,7 @@ describe 'Merge Requests > User filters by milestones', :js do let(:milestone) { create(:milestone, project: project, due_date: Date.tomorrow) } it 'shows merge requests' do - input_filtered_search('milestone:upcoming') + input_filtered_search('milestone=upcoming') expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1) expect(page).to have_css('.merge-request', count: 1) @@ -54,7 +54,7 @@ describe 'Merge Requests > User filters by milestones', :js do let(:milestone) { create(:milestone, project: project, due_date: Date.yesterday) } it 'does not show any merge requests' do - input_filtered_search('milestone:upcoming') + input_filtered_search('milestone=upcoming') expect(page).to have_issuable_counts(open: 0, closed: 0, all: 0) expect(page).to have_css('.merge-request', count: 0) diff --git a/spec/features/merge_requests/user_filters_by_multiple_criteria_spec.rb b/spec/features/merge_requests/user_filters_by_multiple_criteria_spec.rb index bc6e2ac513253b0256ca86e953987160a5c7f4a0..5c9d53778d2b8c62bac20e7c59539dce5083dc2a 100644 --- a/spec/features/merge_requests/user_filters_by_multiple_criteria_spec.rb +++ b/spec/features/merge_requests/user_filters_by_multiple_criteria_spec.rb @@ -20,7 +20,7 @@ describe 'Merge requests > User filters by multiple criteria', :js do describe 'filtering by label:~"Won\'t fix" and assignee:~bug' do it 'applies the filters' do - input_filtered_search("label:~\"Won't fix\" assignee:@#{user.username}") + input_filtered_search("label=~\"Won't fix\" assignee=@#{user.username}") expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1) expect(page).to have_content 'Bugfix2' @@ -30,7 +30,7 @@ describe 'Merge requests > User filters by multiple criteria', :js do describe 'filtering by text, author, assignee, milestone, and label' do it 'filters by text, author, assignee, milestone, and label' do - input_filtered_search_keys("author:@#{user.username} assignee:@#{user.username} milestone:%\"v1.1\" label:~\"Won't fix\" Bug") + input_filtered_search_keys("author=@#{user.username} assignee=@#{user.username} milestone=%\"v1.1\" label=~\"Won't fix\" Bug") expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1) expect(page).to have_content 'Bugfix2' diff --git a/spec/features/merge_requests/user_filters_by_target_branch_spec.rb b/spec/features/merge_requests/user_filters_by_target_branch_spec.rb index 0d03c5eae3115992769f74880d61118190b19e16..faff7de729dadf21c14aef97c6fbc76812feece8 100644 --- a/spec/features/merge_requests/user_filters_by_target_branch_spec.rb +++ b/spec/features/merge_requests/user_filters_by_target_branch_spec.rb @@ -17,7 +17,7 @@ describe 'Merge Requests > User filters by target branch', :js do context 'filtering by target-branch:master' do it 'applies the filter' do - input_filtered_search('target-branch:master') + input_filtered_search('target-branch=master') expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1) expect(page).to have_content mr1.title @@ -27,7 +27,7 @@ describe 'Merge Requests > User filters by target branch', :js do context 'filtering by target-branch:merged-target' do it 'applies the filter' do - input_filtered_search('target-branch:merged-target') + input_filtered_search('target-branch=merged-target') expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1) expect(page).not_to have_content mr1.title @@ -37,7 +37,7 @@ describe 'Merge Requests > User filters by target branch', :js do context 'filtering by target-branch:feature' do it 'applies the filter' do - input_filtered_search('target-branch:feature') + input_filtered_search('target-branch=feature') expect(page).to have_issuable_counts(open: 0, closed: 0, all: 0) expect(page).not_to have_content mr1.title diff --git a/spec/features/profiles/active_sessions_spec.rb b/spec/features/profiles/active_sessions_spec.rb index a5c2d15f5986deb78a0f765213a4ff0f32c369d9..bab6251a5d4841ed5e3d4dc63f8b88bcf09ba4eb 100644 --- a/spec/features/profiles/active_sessions_spec.rb +++ b/spec/features/profiles/active_sessions_spec.rb @@ -84,4 +84,31 @@ describe 'Profile > Active Sessions', :clean_gitlab_redis_shared_state do expect(page).not_to have_content('Chrome on Windows') end end + + it 'User can revoke a session', :js, :redis_session_store do + Capybara::Session.new(:session1) + Capybara::Session.new(:session2) + + # set an additional session in another browser + using_session :session2 do + gitlab_sign_in(user) + end + + using_session :session1 do + gitlab_sign_in(user) + visit profile_active_sessions_path + + expect(page).to have_link('Revoke', count: 1) + + accept_confirm { click_on 'Revoke' } + + expect(page).not_to have_link('Revoke') + end + + using_session :session2 do + visit profile_active_sessions_path + + expect(page).to have_content('You need to sign in or sign up before continuing.') + end + end end diff --git a/spec/features/profiles/user_visits_profile_preferences_page_spec.rb b/spec/features/profiles/user_visits_profile_preferences_page_spec.rb index 4dbdea02e27b483a4580ef26305e525c81cdf21d..b18f763a9683b9bffbdc68a0ecf43fcd9df03819 100644 --- a/spec/features/profiles/user_visits_profile_preferences_page_spec.rb +++ b/spec/features/profiles/user_visits_profile_preferences_page_spec.rb @@ -86,6 +86,23 @@ describe 'User visits the profile preferences page' do end end + describe 'User changes whitespace in code' do + it 'updates their preference' do + expect(user.render_whitespace_in_code).to be(false) + expect(render_whitespace_field).not_to be_checked + render_whitespace_field.click + + click_button 'Save changes' + + expect(user.reload.render_whitespace_in_code).to be(true) + expect(render_whitespace_field).to be_checked + end + end + + def render_whitespace_field + find_field('user[render_whitespace_in_code]') + end + def expect_preferences_saved_message page.within('.flash-container') do expect(page).to have_content('Preferences saved.') diff --git a/spec/features/projects/badges/coverage_spec.rb b/spec/features/projects/badges/coverage_spec.rb index 46aa104fdd7d7d0be3e83a74b2289c56a98a3ed6..dd51eac9be1fd8e4b6d9053a05213c4bf81997e2 100644 --- a/spec/features/projects/badges/coverage_spec.rb +++ b/spec/features/projects/badges/coverage_spec.rb @@ -63,7 +63,7 @@ describe 'test coverage badge' do create(:ci_pipeline, opts).tap do |pipeline| yield pipeline - pipeline.update_status + pipeline.update_legacy_status end end diff --git a/spec/features/projects/blobs/edit_spec.rb b/spec/features/projects/blobs/edit_spec.rb index 0a5bc64b42972f68fa67fc97985a40ec89dd06af..a1d6a8896c7246d1399caa9070f479a845ba69e2 100644 --- a/spec/features/projects/blobs/edit_spec.rb +++ b/spec/features/projects/blobs/edit_spec.rb @@ -12,11 +12,9 @@ describe 'Editing file blob', :js do let(:readme_file_path) { 'README.md' } before do - stub_feature_flags(web_ide_default: false, single_mr_diff_view: false) + stub_feature_flags(web_ide_default: false) end - it_behaves_like 'rendering a single diff version' - context 'as a developer' do let(:user) { create(:user) } let(:role) { :developer } diff --git a/spec/features/projects/environments/environment_metrics_spec.rb b/spec/features/projects/environments/environment_metrics_spec.rb index c027b776d67cde5dd3e7ab13aee8826fe30335d2..d34db5e15cc2241b20064a9875de8aab4bba4f59 100644 --- a/spec/features/projects/environments/environment_metrics_spec.rb +++ b/spec/features/projects/environments/environment_metrics_spec.rb @@ -6,7 +6,7 @@ describe 'Environment > Metrics' do include PrometheusHelpers let(:user) { create(:user) } - let(:project) { create(:prometheus_project) } + let(:project) { create(:prometheus_project, :repository) } let(:pipeline) { create(:ci_pipeline, project: project) } let(:build) { create(:ci_build, pipeline: pipeline) } let(:environment) { create(:environment, project: project) } diff --git a/spec/features/projects/environments/environment_spec.rb b/spec/features/projects/environments/environment_spec.rb index 3eab13cb8206b52ce45cb633f415d515f02d990c..bbd33225bb9fd70ac63accf14cf34f60f62e3380 100644 --- a/spec/features/projects/environments/environment_spec.rb +++ b/spec/features/projects/environments/environment_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' describe 'Environment' do - let(:project) { create(:project) } + let(:project) { create(:project, :repository) } let(:user) { create(:user) } let(:role) { :developer } @@ -12,11 +12,16 @@ describe 'Environment' do project.add_role(user, role) end + def auto_stop_button_selector + %q{button[title="Prevent environment from auto-stopping"]} + end + describe 'environment details page' do let!(:environment) { create(:environment, project: project) } let!(:permissions) { } let!(:deployment) { } let!(:action) { } + let!(:cluster) { } before do visit_environment(environment) @@ -26,6 +31,40 @@ describe 'Environment' do expect(page).to have_content(environment.name) end + context 'without auto-stop' do + it 'does not show auto-stop text' do + expect(page).not_to have_content('Auto stops') + end + + it 'does not show auto-stop button' do + expect(page).not_to have_selector(auto_stop_button_selector) + end + end + + context 'with auto-stop' do + let!(:environment) { create(:environment, :will_auto_stop, name: 'staging', project: project) } + + before do + visit_environment(environment) + end + + it 'shows auto stop info' do + expect(page).to have_content('Auto stops') + end + + it 'shows auto stop button' do + expect(page).to have_selector(auto_stop_button_selector) + expect(page.find(auto_stop_button_selector).find(:xpath, '..')['action']).to have_content(cancel_auto_stop_project_environment_path(environment.project, environment)) + end + + it 'allows user to cancel auto stop', :js do + page.find(auto_stop_button_selector).click + wait_for_all_requests + expect(page).to have_content('Auto stop successfully canceled.') + expect(page).not_to have_selector(auto_stop_button_selector) + end + end + context 'without deployments' do it 'does not show deployments' do expect(page).to have_content('You don\'t have any deployments right now.') @@ -94,19 +133,10 @@ describe 'Environment' do it 'does show build name' do expect(page).to have_link("#{build.name} (##{build.id})") - expect(page).not_to have_link('Re-deploy') - expect(page).not_to have_terminal_button end - context 'when user has ability to re-deploy' do - let(:permissions) do - create(:protected_branch, :developers_can_merge, - name: build.ref, project: project) - end - - it 'does show re-deploy' do - expect(page).to have_link('Re-deploy') - end + it 'shows the re-deploy button' do + expect(page).to have_button('Re-deploy to environment') end context 'with manual action' do @@ -141,6 +171,11 @@ describe 'Environment' do end context 'when user has no ability to trigger a deployment' do + let(:permissions) do + create(:protected_branch, :no_one_can_merge, + name: action.ref, project: project) + end + it 'does not show a play button' do expect(page).not_to have_link(action.name) end @@ -158,8 +193,9 @@ describe 'Environment' do context 'with terminal' do context 'when user configured kubernetes from CI/CD > Clusters' do - let!(:cluster) { create(:cluster, :project, :provided_by_gcp) } - let(:project) { cluster.project } + let!(:cluster) do + create(:cluster, :project, :provided_by_gcp, projects: [project]) + end context 'for project maintainer' do let(:role) { :maintainer } @@ -228,6 +264,11 @@ describe 'Environment' do end context 'when user has no ability to stop environment' do + let(:permissions) do + create(:protected_branch, :no_one_can_merge, + name: action.ref, project: project) + end + it 'does not allow to stop environment' do expect(page).not_to have_button('Stop') end diff --git a/spec/features/projects/features_visibility_spec.rb b/spec/features/projects/features_visibility_spec.rb index a825911b01a1eb925aa5d950aa5a91412cbf584b..9854335a7ad07a035efe042dc84d5aac926bf5e5 100644 --- a/spec/features/projects/features_visibility_spec.rb +++ b/spec/features/projects/features_visibility_spec.rb @@ -186,7 +186,7 @@ describe 'Edit Project Settings' do click_button "Save changes" end - expect(find(".sharing-permissions")).to have_selector(".project-feature-toggle.is-disabled", count: 2) + expect(find(".sharing-permissions")).to have_selector(".project-feature-toggle.is-disabled", count: 3) end it "shows empty features project homepage" do diff --git a/spec/features/projects/files/user_browses_files_spec.rb b/spec/features/projects/files/user_browses_files_spec.rb index 10672bbec68b75f447271e56fd42b185c23c09e3..b8efabb0cabc185aa7fa915d1c32390f64606645 100644 --- a/spec/features/projects/files/user_browses_files_spec.rb +++ b/spec/features/projects/files/user_browses_files_spec.rb @@ -41,6 +41,11 @@ describe "User browses files" do it "shows the `Browse Directory` link" do click_link("files") + + page.within('.repo-breadcrumb') do + expect(page).to have_link('files') + end + click_link("History") expect(page).to have_link("Browse Directory").and have_no_link("Browse Code") @@ -229,6 +234,16 @@ describe "User browses files" do expect(page).to have_content("*.rb") .and have_content("Dmitriy Zaporozhets") .and have_content("Initial commit") + .and have_content("Ignore DS files") + + previous_commit_anchor = "//a[@title='Ignore DS files']/parent::span/following-sibling::span/a" + find(:xpath, previous_commit_anchor).click + + expect(page).to have_content("*.rb") + .and have_content("Dmitriy Zaporozhets") + .and have_content("Initial commit") + + expect(page).not_to have_content("Ignore DS files") end end diff --git a/spec/features/projects/files/user_browses_lfs_files_spec.rb b/spec/features/projects/files/user_browses_lfs_files_spec.rb index 618290416bd3aeaa11e5985ad8dd652b039eceb9..dbeec973865a6502754674cdba01907e970d4ae7 100644 --- a/spec/features/projects/files/user_browses_lfs_files_spec.rb +++ b/spec/features/projects/files/user_browses_lfs_files_spec.rb @@ -19,7 +19,17 @@ describe 'Projects > Files > User browses LFS files' do it 'is possible to see raw content of LFS pointer' do click_link 'files' + + page.within('.repo-breadcrumb') do + expect(page).to have_link('files') + end + click_link 'lfs' + + page.within('.repo-breadcrumb') do + expect(page).to have_link('lfs') + end + click_link 'lfs_object.iso' expect(page).to have_content 'version https://git-lfs.github.com/spec/v1' @@ -38,6 +48,11 @@ describe 'Projects > Files > User browses LFS files' do it 'shows an LFS object' do click_link('files') + + page.within('.repo-breadcrumb') do + expect(page).to have_link('files') + end + click_link('lfs') click_link('lfs_object.iso') diff --git a/spec/features/projects/fork_spec.rb b/spec/features/projects/fork_spec.rb index 0f97032eefa9e366b562dd34dc6a3df08d55c5bf..bfab4387688d87f770ad69173f2c1a41e221e60b 100644 --- a/spec/features/projects/fork_spec.rb +++ b/spec/features/projects/fork_spec.rb @@ -27,6 +27,89 @@ describe 'Project fork' do expect(page).to have_css('a.disabled', text: 'Fork') end + context 'forking enabled / disabled in project settings' do + before do + project.project_feature.update_attribute( + :forking_access_level, forking_access_level) + end + + context 'forking is enabled' do + let(:forking_access_level) { ProjectFeature::ENABLED } + + it 'enables fork button' do + visit project_path(project) + + expect(page).to have_css('a', text: 'Fork') + expect(page).not_to have_css('a.disabled', text: 'Fork') + end + + it 'renders new project fork page' do + visit new_project_fork_path(project) + + expect(page.status_code).to eq(200) + expect(page).to have_text(' Select a namespace to fork the project ') + end + end + + context 'forking is disabled' do + let(:forking_access_level) { ProjectFeature::DISABLED } + + it 'does not render fork button' do + visit project_path(project) + + expect(page).not_to have_css('a', text: 'Fork') + end + + it 'does not render new project fork page' do + visit new_project_fork_path(project) + + expect(page.status_code).to eq(404) + end + end + + context 'forking is private' do + let(:forking_access_level) { ProjectFeature::PRIVATE } + + before do + project.update(visibility_level: Gitlab::VisibilityLevel::INTERNAL) + end + + context 'user is not a team member' do + it 'does not render fork button' do + visit project_path(project) + + expect(page).not_to have_css('a', text: 'Fork') + end + + it 'does not render new project fork page' do + visit new_project_fork_path(project) + + expect(page.status_code).to eq(404) + end + end + + context 'user is a team member' do + before do + project.add_developer(user) + end + + it 'enables fork button' do + visit project_path(project) + + expect(page).to have_css('a', text: 'Fork') + expect(page).not_to have_css('a.disabled', text: 'Fork') + end + + it 'renders new project fork page' do + visit new_project_fork_path(project) + + expect(page.status_code).to eq(200) + expect(page).to have_text(' Select a namespace to fork the project ') + end + end + end + end + it 'forks the project', :sidekiq_might_not_need_inline do visit project_path(project) diff --git a/spec/features/projects/jobs_spec.rb b/spec/features/projects/jobs_spec.rb index 26ba7ae7a292478e9e2fcd156154153d2ad66e40..f9ff076a4168857243a68a91b9610fafe5069d67 100644 --- a/spec/features/projects/jobs_spec.rb +++ b/spec/features/projects/jobs_spec.rb @@ -306,6 +306,21 @@ describe 'Jobs', :clean_gitlab_redis_shared_state do end end + context 'when job is waiting for resource', :js do + let(:job) { create(:ci_build, :waiting_for_resource, pipeline: pipeline, resource_group: resource_group) } + let(:resource_group) { create(:ci_resource_group, project: project) } + + before do + visit project_job_path(project, job) + wait_for_requests + end + + it 'shows correct UI components' do + expect(page).to have_content("This job is waiting for resource: #{resource_group.key}") + expect(page).to have_link("Cancel this job") + end + end + context "Job from other project" do before do visit project_job_path(project, job2) diff --git a/spec/features/projects/members/list_spec.rb b/spec/features/projects/members/list_spec.rb index 6d92c7770332805633cbe45fb66fa2b00ce02185..84000ef73ce5c8c998f9cbcd33a190ac76c4f0e6 100644 --- a/spec/features/projects/members/list_spec.rb +++ b/spec/features/projects/members/list_spec.rb @@ -87,12 +87,12 @@ describe 'Project members list' do end def add_user(id, role) - page.within ".users-project-form" do + page.within ".invite-users-form" do select2(id, from: "#user_ids", multiple: true) select(role, from: "access_level") end - click_button "Add to project" + click_button "Invite" end def visit_members_page diff --git a/spec/features/projects/members/master_adds_member_with_expiration_date_spec.rb b/spec/features/projects/members/master_adds_member_with_expiration_date_spec.rb index 501dd05300aa0c12230f7fd62fd670d3e7c892d0..cbcd03b33cef7d4a87a9e0a0b5983510f5118a7b 100644 --- a/spec/features/projects/members/master_adds_member_with_expiration_date_spec.rb +++ b/spec/features/projects/members/master_adds_member_with_expiration_date_spec.rb @@ -20,10 +20,10 @@ describe 'Projects > Members > Maintainer adds member with expiration date', :js date = 4.days.from_now visit project_project_members_path(project) - page.within '.users-project-form' do + page.within '.invite-users-form' do select2(new_member.id, from: '#user_ids', multiple: true) fill_in 'expires_at', with: date.to_s(:medium) + "\n" - click_on 'Add to project' + click_on 'Invite' end page.within "#project_member_#{new_member.project_members.first.id}" do diff --git a/spec/features/projects/pages_spec.rb b/spec/features/projects/pages_spec.rb index 3c4b5b2c4ca641d64f443f65e465fe678c4e96cd..c8da87041f9bb1cb027f7b25bc4b462c443b9aaf 100644 --- a/spec/features/projects/pages_spec.rb +++ b/spec/features/projects/pages_spec.rb @@ -322,7 +322,7 @@ shared_examples 'pages settings editing' do before do allow(Projects::UpdateService).to receive(:new).and_return(service) - allow(service).to receive(:execute).and_return(status: :error) + allow(service).to receive(:execute).and_return(status: :error, message: 'Some error has occured') end it 'tries to change the setting' do @@ -332,7 +332,7 @@ shared_examples 'pages settings editing' do click_button 'Save' - expect(page).to have_text('Something went wrong on our end') + expect(page).to have_text('Some error has occured') end end @@ -347,7 +347,7 @@ shared_examples 'pages settings editing' do visit project_pages_path(project) expect(page).to have_field(:project_pages_https_only, disabled: true) - expect(page).not_to have_button('Save') + expect(page).to have_button('Save') end end diff --git a/spec/features/projects/pipelines/pipeline_spec.rb b/spec/features/projects/pipelines/pipeline_spec.rb index 94fac9a2eb5d99ef7c15157708143a6b421fd8b5..198af65c3619571b5e47cfec8b73394057423bc1 100644 --- a/spec/features/projects/pipelines/pipeline_spec.rb +++ b/spec/features/projects/pipelines/pipeline_spec.rb @@ -59,7 +59,8 @@ describe 'Pipeline', :js do describe 'GET /:project/pipelines/:id' do include_context 'pipeline builds' - let(:project) { create(:project, :repository) } + let(:group) { create(:group) } + let(:project) { create(:project, :repository, group: group) } let(:pipeline) { create(:ci_pipeline, project: project, ref: 'master', sha: project.commit.id, user: user) } subject(:visit_pipeline) { visit project_pipeline_path(project, pipeline) } @@ -329,6 +330,32 @@ describe 'Pipeline', :js do end end + context 'deleting pipeline' do + context 'when user can not delete' do + before do + visit_pipeline + end + + it { expect(page).not_to have_button('Delete') } + end + + context 'when deleting' do + before do + group.add_owner(user) + + visit_pipeline + + click_button 'Delete' + click_button 'Delete pipeline' + end + + it 'redirects to pipeline overview page', :sidekiq_might_not_need_inline do + expect(page).to have_content('The pipeline has been deleted') + expect(current_path).to eq(project_pipelines_path(project)) + end + end + end + context 'when pipeline ref does not exist in repository anymore' do let(:pipeline) do create(:ci_empty_pipeline, project: project, @@ -606,6 +633,117 @@ describe 'Pipeline', :js do end end + context 'when build requires resource', :sidekiq_inline do + let_it_be(:project) { create(:project, :repository) } + let(:pipeline) { create(:ci_pipeline, project: project) } + let(:resource_group) { create(:ci_resource_group, project: project) } + + let!(:test_job) do + create(:ci_build, :pending, stage: 'test', name: 'test', + stage_idx: 1, pipeline: pipeline, project: project) + end + + let!(:deploy_job) do + create(:ci_build, :created, stage: 'deploy', name: 'deploy', + stage_idx: 2, pipeline: pipeline, project: project, resource_group: resource_group) + end + + describe 'GET /:project/pipelines/:id' do + subject { visit project_pipeline_path(project, pipeline) } + + it 'shows deploy job as created' do + subject + + within('.pipeline-header-container') do + expect(page).to have_content('pending') + end + + within('.pipeline-graph') do + within '.stage-column:nth-child(1)' do + expect(page).to have_content('test') + expect(page).to have_css('.ci-status-icon-pending') + end + + within '.stage-column:nth-child(2)' do + expect(page).to have_content('deploy') + expect(page).to have_css('.ci-status-icon-created') + end + end + end + + context 'when test job succeeded' do + before do + test_job.success! + end + + it 'shows deploy job as pending' do + subject + + within('.pipeline-header-container') do + expect(page).to have_content('running') + end + + within('.pipeline-graph') do + within '.stage-column:nth-child(1)' do + expect(page).to have_content('test') + expect(page).to have_css('.ci-status-icon-success') + end + + within '.stage-column:nth-child(2)' do + expect(page).to have_content('deploy') + expect(page).to have_css('.ci-status-icon-pending') + end + end + end + end + + context 'when test job succeeded but there are no available resources' do + let(:another_job) { create(:ci_build, :running, project: project, resource_group: resource_group) } + + before do + resource_group.assign_resource_to(another_job) + test_job.success! + end + + it 'shows deploy job as waiting for resource' do + subject + + within('.pipeline-header-container') do + expect(page).to have_content('waiting') + end + + within('.pipeline-graph') do + within '.stage-column:nth-child(2)' do + expect(page).to have_content('deploy') + expect(page).to have_css('.ci-status-icon-waiting-for-resource') + end + end + end + + context 'when resource is released from another job' do + before do + another_job.success! + end + + it 'shows deploy job as pending' do + subject + + within('.pipeline-header-container') do + expect(page).to have_content('running') + end + + within('.pipeline-graph') do + within '.stage-column:nth-child(2)' do + expect(page).to have_content('deploy') + expect(page).to have_css('.ci-status-icon-pending') + end + end + end + end + end + end + end + describe 'GET /:project/pipelines/:id/builds' do include_context 'pipeline builds' diff --git a/spec/features/projects/raw/user_interacts_with_raw_endpoint_spec.rb b/spec/features/projects/raw/user_interacts_with_raw_endpoint_spec.rb index 6d587053b4f81056260875fb839087c5e51ac59c..673766073a2011a6da6bcf5fd421992050457f43 100644 --- a/spec/features/projects/raw/user_interacts_with_raw_endpoint_spec.rb +++ b/spec/features/projects/raw/user_interacts_with_raw_endpoint_spec.rb @@ -31,8 +31,6 @@ describe 'Projects > Raw > User interacts with raw endpoint' do visit project_raw_url(project, file_path) end - expect(source).to have_content('You are being redirected') - click_link('redirected') expect(page).to have_content('You cannot access the raw file. Please wait a minute.') end end diff --git a/spec/features/projects/serverless/functions_spec.rb b/spec/features/projects/serverless/functions_spec.rb index e82e5b81021d6000c81abda5f7c8f1feba034a7f..c661ceb8edadb9a86cfaba7c7f335dd266b3f300 100644 --- a/spec/features/projects/serverless/functions_spec.rb +++ b/spec/features/projects/serverless/functions_spec.rb @@ -6,7 +6,7 @@ describe 'Functions', :js do include KubernetesHelpers include ReactiveCachingHelpers - let(:project) { create(:project) } + let(:project) { create(:project, :repository) } let(:user) { create(:user) } before do @@ -36,9 +36,8 @@ describe 'Functions', :js do 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(:cluster) { create(:cluster, :project, :provided_by_gcp, projects: [project]) } let(:service) { cluster.platform_kubernetes } - let(:project) { cluster.project } let(:environment) { create(:environment, project: project) } let!(:deployment) { create(:deployment, :success, cluster: cluster, environment: environment) } let(:knative_services_finder) { environment.knative_services_finder } diff --git a/spec/features/projects/settings/project_settings_spec.rb b/spec/features/projects/settings/project_settings_spec.rb index 7afddc0e712ac51d21bea28f711eae8655489dca..b601866c96b95fb2e1eb2235c1a33631da888114 100644 --- a/spec/features/projects/settings/project_settings_spec.rb +++ b/spec/features/projects/settings/project_settings_spec.rb @@ -34,6 +34,26 @@ describe 'Projects settings' do expect_toggle_state(:expanded) end + context 'forking enabled', :js do + it 'toggles forking enabled / disabled' do + visit edit_project_path(project) + + forking_enabled_input = find('input[name="project[project_feature_attributes][forking_access_level]"]', visible: :hidden) + forking_enabled_button = find('input[name="project[project_feature_attributes][forking_access_level]"] + label > button') + + expect(forking_enabled_input.value).to eq('20') + + # disable by clicking toggle + forking_enabled_button.click + page.within('.sharing-permissions') do + find('input[value="Save changes"]').click + end + wait_for_requests + + expect(forking_enabled_input.value).to eq('0') + end + end + def expect_toggle_state(state) is_collapsed = state == :collapsed diff --git a/spec/features/projects/settings/registry_settings_spec.rb b/spec/features/projects/settings/registry_settings_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..86da866a9274c62d6fcd474725b1d27c3688eb09 --- /dev/null +++ b/spec/features/projects/settings/registry_settings_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'Project > Settings > CI/CD > Container registry tag expiration policy', :js do + let(:user) { create(:user) } + let(:project) { create(:project, namespace: user.namespace) } + + context 'as owner' do + before do + sign_in(user) + visit project_settings_ci_cd_path(project) + end + + it 'section is available' do + settings_block = find('#js-registry-policies') + expect(settings_block).to have_text 'Container Registry tag expiration policy' + end + + it 'Save expiration policy submit the form', :js do + within '#js-registry-policies' do + within '.card-body' do + click_button(class: 'gl-toggle') + select('7 days until tags are automatically removed', from: 'expiration-policy-interval') + select('Every day', from: 'expiration-policy-schedule') + select('50 tags per image name', from: 'expiration-policy-latest') + fill_in('expiration-policy-name-matching', with: '*-production') + end + submit_button = find('.card-footer .btn.btn-success') + expect(submit_button).not_to be_disabled + submit_button.click + end + flash_text = find('.flash-text') + expect(flash_text).to have_content('Expiration policy successfully saved.') + end + end +end diff --git a/spec/features/projects/settings/user_manages_project_members_spec.rb b/spec/features/projects/settings/user_manages_project_members_spec.rb index 6d94388a6e2c00f2028f9c25497528e5dcc331ee..705c60f15ee0f1c8fa7bd033f4113a06bb60b888 100644 --- a/spec/features/projects/settings/user_manages_project_members_spec.rb +++ b/spec/features/projects/settings/user_manages_project_members_spec.rb @@ -37,7 +37,7 @@ describe 'Projects > Settings > User manages project members' do visit(project_project_members_path(project)) - page.within('.users-project-form') do + page.within('.invite-users-form') do click_link('Import') end diff --git a/spec/features/projects/settings/user_renames_a_project_spec.rb b/spec/features/projects/settings/user_renames_a_project_spec.rb index d2daf8b922da225c1460f27a5f19b88553b61f71..789c5e317488b089768fd08aefd62c80b0ecb4c7 100644 --- a/spec/features/projects/settings/user_renames_a_project_spec.rb +++ b/spec/features/projects/settings/user_renames_a_project_spec.rb @@ -59,8 +59,8 @@ describe 'Projects > Settings > User renames a project' do context 'with emojis' do it 'shows error for invalid project name' do - change_name(project, '🚀 foo bar â˜ï¸') - expect(page).to have_field 'Project name', with: '🚀 foo bar â˜ï¸' + change_name(project, '🧮 foo bar â˜ï¸') + expect(page).to have_field 'Project name', with: '🧮 foo bar â˜ï¸' expect(page).not_to have_content "Name can contain only letters, digits, emojis '_', '.', dash and space. It must start with letter, digit, emoji or '_'." end end diff --git a/spec/features/projects/snippets/create_snippet_spec.rb b/spec/features/projects/snippets/create_snippet_spec.rb index ad65e04473c1c727f90f09a1aca5f6a80cacb047..94af023e8046e8ccdf5ecf1ce260c83c1b92b6e4 100644 --- a/spec/features/projects/snippets/create_snippet_spec.rb +++ b/spec/features/projects/snippets/create_snippet_spec.rb @@ -50,7 +50,7 @@ describe 'Projects > Snippets > Create Snippet', :js do wait_for_requests link = find('a.no-attachment-icon img[alt="banana_sample"]')['src'] - expect(link).to match(%r{/#{Regexp.escape(project.full_path) }/uploads/\h{32}/banana_sample\.gif\z}) + expect(link).to match(%r{/#{Regexp.escape(project.full_path)}/uploads/\h{32}/banana_sample\.gif\z}) end it 'creates a snippet when all required fields are filled in after validation failing' do @@ -72,7 +72,7 @@ describe 'Projects > Snippets > Create Snippet', :js do expect(page).to have_selector('strong') end link = find('a.no-attachment-icon img[alt="banana_sample"]')['src'] - expect(link).to match(%r{/#{Regexp.escape(project.full_path) }/uploads/\h{32}/banana_sample\.gif\z}) + expect(link).to match(%r{/#{Regexp.escape(project.full_path)}/uploads/\h{32}/banana_sample\.gif\z}) end end diff --git a/spec/features/projects/sourcegraph_csp_spec.rb b/spec/features/projects/sourcegraph_csp_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..57d1e8e3034f57de7f1e4a64f19adb76547f6a3a --- /dev/null +++ b/spec/features/projects/sourcegraph_csp_spec.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'Sourcegraph Content Security Policy' do + let_it_be(:user) { create(:user) } + let_it_be(:project) { create(:project, :repository, namespace: user.namespace) } + let_it_be(:default_csp_values) { "'self' https://some-cdn.test" } + let_it_be(:sourcegraph_url) { 'https://sourcegraph.test' } + let(:sourcegraph_enabled) { true } + + subject do + visit project_blob_path(project, File.join('master', 'README.md')) + + response_headers['Content-Security-Policy'] + end + + before do + allow(Gitlab::CurrentSettings).to receive(:sourcegraph_url).and_return(sourcegraph_url) + allow(Gitlab::CurrentSettings).to receive(:sourcegraph_enabled).and_return(sourcegraph_enabled) + + sign_in(user) + end + + shared_context 'csp config' do |csp_rule| + before do + csp = ActionDispatch::ContentSecurityPolicy.new do |p| + p.send(csp_rule, default_csp_values) if csp_rule + end + + expect_next_instance_of(Projects::BlobController) do |controller| + expect(controller).to receive(:current_content_security_policy).and_return(csp) + end + end + end + + context 'when no CSP config' do + include_context 'csp config', nil + + it 'does not add CSP directives' do + is_expected.to be_blank + end + end + + describe 'when a CSP config exists for connect-src' do + include_context 'csp config', :connect_src + + context 'when sourcegraph enabled' do + it 'appends to connect-src' do + is_expected.to eql("connect-src #{default_csp_values} #{sourcegraph_url}") + end + end + + context 'when sourcegraph disabled' do + let(:sourcegraph_enabled) { false } + + it 'keeps original connect-src' do + is_expected.to eql("connect-src #{default_csp_values}") + end + end + end + + describe 'when a CSP config exists for default-src but not connect-src' do + include_context 'csp config', :default_src + + context 'when sourcegraph enabled' do + it 'uses default-src values in connect-src' do + is_expected.to eql("default-src #{default_csp_values}; connect-src #{default_csp_values} #{sourcegraph_url}") + end + end + + context 'when sourcegraph disabled' do + let(:sourcegraph_enabled) { false } + + it 'does not add connect-src' do + is_expected.to eql("default-src #{default_csp_values}") + end + end + end + + describe 'when a CSP config exists for font-src but not connect-src' do + include_context 'csp config', :font_src + + context 'when sourcegraph enabled' do + it 'uses default-src values in connect-src' do + is_expected.to eql("font-src #{default_csp_values}; connect-src #{sourcegraph_url}") + end + end + + context 'when sourcegraph disabled' do + let(:sourcegraph_enabled) { false } + + it 'does not add connect-src' do + is_expected.to eql("font-src #{default_csp_values}") + end + end + end +end diff --git a/spec/features/projects/tree/create_directory_spec.rb b/spec/features/projects/tree/create_directory_spec.rb index 99285011405ef79ca1a07fbb9bad8a5af8699d44..7e0ee861b18116b2022979533acc31e30f7bc6ba 100644 --- a/spec/features/projects/tree/create_directory_spec.rb +++ b/spec/features/projects/tree/create_directory_spec.rb @@ -46,8 +46,6 @@ describe 'Multi-file editor new directory', :js do find('.js-ide-commit-mode').click - click_button 'Stage' - fill_in('commit-message', with: 'commit message ide') find(:css, ".js-ide-commit-new-mr input").set(false) diff --git a/spec/features/projects/tree/create_file_spec.rb b/spec/features/projects/tree/create_file_spec.rb index 780575a59755f92eab7eff2ee26cfff3204a2cbb..eba33168006caabe820529a4ff5ae2043e28b6c9 100644 --- a/spec/features/projects/tree/create_file_spec.rb +++ b/spec/features/projects/tree/create_file_spec.rb @@ -36,8 +36,6 @@ describe 'Multi-file editor new file', :js do find('.js-ide-commit-mode').click - click_button 'Stage' - fill_in('commit-message', with: 'commit message ide') find(:css, ".js-ide-commit-new-mr input").set(false) diff --git a/spec/features/projects/view_on_env_spec.rb b/spec/features/projects/view_on_env_spec.rb index c2d4cefad12f2ce6b402139d99394c7ee8183372..8b25565c08a634f020a189c19b386a15a6d97cda 100644 --- a/spec/features/projects/view_on_env_spec.rb +++ b/spec/features/projects/view_on_env_spec.rb @@ -9,14 +9,11 @@ describe 'View on environment', :js do let(:user) { project.creator } before do - stub_feature_flags(single_mr_diff_view: false) stub_feature_flags(diffs_batch_load: false) project.add_maintainer(user) end - it_behaves_like 'rendering a single diff version' - context 'when the branch has a route map' do let(:route_map) do <<-MAP.strip_heredoc 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 499c459621adef2c5017935c0efcc10afbeedfe1..7503c8aa52ec09f17625e42711aaad6b865c5a18 100644 --- a/spec/features/projects/wiki/user_creates_wiki_page_spec.rb +++ b/spec/features/projects/wiki/user_creates_wiki_page_spec.rb @@ -145,6 +145,24 @@ describe "User creates wiki page" do end end + it 'creates a wiki page with Org markup', :aggregate_failures do + org_content = <<~ORG + * Heading + ** Subheading + [[home][Link to Home]] + ORG + + page.within('.wiki-form') do + find('#wiki_format option[value=org]').select_option + fill_in(:wiki_content, with: org_content) + click_button('Create page') + end + + expect(page).to have_selector('h1', text: 'Heading') + expect(page).to have_selector('h2', text: 'Subheading') + expect(page).to have_link('Link to Home', href: "/#{project.full_path}/-/wikis/home") + end + it_behaves_like 'wiki file attachments', :quarantine end diff --git a/spec/features/projects/wiki/users_views_asciidoc_page_with_includes_spec.rb b/spec/features/projects/wiki/users_views_asciidoc_page_with_includes_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..08eea14c438febcfd0252372d366533fe9ff0177 --- /dev/null +++ b/spec/features/projects/wiki/users_views_asciidoc_page_with_includes_spec.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'User views AsciiDoc page with includes', :js do + let_it_be(:user) { create(:user) } + let_it_be(:wiki_content_selector) { '[data-qa-selector=wiki_page_content]' } + let(:project) { create(:project, :public, :wiki_repo) } + let!(:included_wiki_page) { create_wiki_page('included_page', content: 'Content from the included page')} + let!(:wiki_page) { create_wiki_page('home', content: "Content from the main page.\ninclude::included_page.asciidoc[]") } + + def create_wiki_page(title, content:) + attrs = { + title: title, + content: content, + format: :asciidoc + } + + create(:wiki_page, wiki: project.wiki, attrs: attrs) + end + + before do + sign_in(user) + end + + context 'when the file being included exists' do + it 'includes the file contents' do + visit(project_wiki_path(project, wiki_page)) + + page.within(:css, wiki_content_selector) do + expect(page).to have_content('Content from the main page. Content from the included page') + end + end + + context 'when there are multiple versions of the wiki pages' do + before do + included_wiki_page.update(message: 'updated included file', content: 'Updated content from the included page') + wiki_page.update(message: 'updated wiki page', content: "Updated content from the main page.\ninclude::included_page.asciidoc[]") + end + + let(:latest_version_id) { wiki_page.versions.first.id } + let(:oldest_version_id) { wiki_page.versions.last.id } + + context 'viewing the latest version' do + it 'includes the latest content' do + visit(project_wiki_path(project, wiki_page, version_id: latest_version_id)) + + page.within(:css, wiki_content_selector) do + expect(page).to have_content('Updated content from the main page. Updated content from the included page') + end + end + end + + context 'viewing the original version' do + it 'includes the content from the original version' do + visit(project_wiki_path(project, wiki_page, version_id: oldest_version_id)) + + page.within(:css, wiki_content_selector) do + expect(page).to have_content('Content from the main page. Content from the included page') + end + end + end + end + end + + context 'when the file being included does not exist' do + before do + included_wiki_page.delete + end + + it 'outputs an error' do + visit(project_wiki_path(project, wiki_page)) + + page.within(:css, wiki_content_selector) do + expect(page).to have_content('Content from the main page. [ERROR: include::included_page.asciidoc[] - unresolved directive]') + end + end + end +end diff --git a/spec/features/task_lists_spec.rb b/spec/features/task_lists_spec.rb index 11429f16f420c9ea9231b4b1118de4aed2bb559b..bcd894a0d20989b7710f3cdeec7b3699ca25d6ce 100644 --- a/spec/features/task_lists_spec.rb +++ b/spec/features/task_lists_spec.rb @@ -51,6 +51,27 @@ describe 'Task Lists' do EOT end + let(:commented_tasks_markdown) do + <<-EOT.strip_heredoc + <!-- + - [ ] a + --> + + - [ ] b + EOT + end + + let(:summary_no_blank_line_markdown) do + <<-EOT.strip_heredoc + <details> + <summary>No blank line after summary element breaks task list</summary> + 1. [ ] People Ops: do such and such + </details> + + * [ ] Task 1 + EOT + end + before do Warden.test_mode! @@ -291,4 +312,52 @@ describe 'Task Lists' do end end end + + describe 'markdown task edge cases' do + describe 'commented tasks', :js do + let!(:issue) { create(:issue, description: commented_tasks_markdown, author: user, project: project) } + + it 'renders' do + visit_issue(project, issue) + wait_for_requests + + expect(page).to have_selector('ul.task-list', count: 1) + expect(page).to have_selector('li.task-list-item', count: 1) + expect(page).to have_selector('ul input[checked]', count: 0) + + find('.task-list-item-checkbox').click + wait_for_requests + + visit_issue(project, issue) + wait_for_requests + + expect(page).to have_selector('ul.task-list', count: 1) + expect(page).to have_selector('li.task-list-item', count: 1) + expect(page).to have_selector('ul input[checked]', count: 1) + end + end + + describe 'summary with no blank line', :js do + let!(:issue) { create(:issue, description: summary_no_blank_line_markdown, author: user, project: project) } + + it 'renders' do + visit_issue(project, issue) + wait_for_requests + + expect(page).to have_selector('ul.task-list', count: 1) + expect(page).to have_selector('li.task-list-item', count: 1) + expect(page).to have_selector('ul input[checked]', count: 0) + + find('.task-list-item-checkbox').click + wait_for_requests + + visit_issue(project, issue) + wait_for_requests + + expect(page).to have_selector('ul.task-list', count: 1) + expect(page).to have_selector('li.task-list-item', count: 1) + expect(page).to have_selector('ul input[checked]', count: 1) + end + end + end end diff --git a/spec/features/triggers_spec.rb b/spec/features/triggers_spec.rb index 19cd21e4161960eafcebc56450195159364c32bf..af406961bbc7d917fc94c3f0b9899eb7c409b1b0 100644 --- a/spec/features/triggers_spec.rb +++ b/spec/features/triggers_spec.rb @@ -65,22 +65,6 @@ describe 'Triggers', :js do expect(page.find('.triggers-list')).to have_content new_trigger_title expect(page.find('.triggers-list .trigger-owner')).to have_content user.name end - - it 'edit "legacy" trigger and save' do - # Create new trigger without owner association, i.e. Legacy trigger - create(:ci_trigger, owner: user, project: @project).update_attribute(:owner, nil) - visit project_settings_ci_cd_path(@project) - - # See if the trigger can be edited and description is blank - find('a[title="Edit"]').send_keys(:return) - expect(page.find('#trigger_description').value).to have_content '' - - # See if trigger can be updated with description and saved successfully - fill_in 'trigger_description', with: new_trigger_title - click_button 'Save trigger' - expect(page.find('.flash-notice')).to have_content 'Trigger was successfully updated.' - expect(page.find('.triggers-list')).to have_content new_trigger_title - end end describe 'trigger "Revoke" workflow' do @@ -106,43 +90,18 @@ describe 'Triggers', :js do end describe 'show triggers workflow' do - before do - stub_feature_flags(use_legacy_pipeline_triggers: false) - end - it 'contains trigger description placeholder' do expect(page.find('#trigger_description')['placeholder']).to eq 'Trigger description' end - it 'show "invalid" badge for legacy trigger' do - create(:ci_trigger, owner: user, project: @project).update_attribute(:owner, nil) - visit project_settings_ci_cd_path(@project) - - expect(page.find('.triggers-list')).to have_content 'invalid' - end - it 'show "invalid" badge for trigger with owner having insufficient permissions' do create(:ci_trigger, owner: guest_user, project: @project, description: trigger_title) visit project_settings_ci_cd_path(@project) - # See if trigger without owner (i.e. legacy) shows "legacy" badge and is non-editable expect(page.find('.triggers-list')).to have_content 'invalid' expect(page.find('.triggers-list')).not_to have_selector('a[title="Edit"]') end - it 'do not show "Edit" or full token for legacy trigger' do - create(:ci_trigger, owner: user, project: @project, description: trigger_title) - .update_attribute(:owner, nil) - visit project_settings_ci_cd_path(@project) - - # See if trigger not owned shows only first few token chars and doesn't have copy-to-clipboard button - expect(page.find('.triggers-list')).to have_content(@project.triggers.first.token[0..3]) - expect(page.find('.triggers-list')).not_to have_selector('button.btn-clipboard') - - # See if trigger is non-editable - expect(page.find('.triggers-list')).not_to have_selector('a[title="Edit"]') - end - it 'do not show "Edit" or full token for not owned trigger' do # Create trigger with user different from current_user create(:ci_trigger, owner: user2, project: @project, description: trigger_title) @@ -169,56 +128,5 @@ describe 'Triggers', :js do expect(page.find('.triggers-list .trigger-owner')).to have_content user.name expect(page.find('.triggers-list')).to have_selector('a[title="Edit"]') end - - context 'when :use_legacy_pipeline_triggers feature flag is enabled' do - before do - stub_feature_flags(use_legacy_pipeline_triggers: true) - end - - it 'show "legacy" badge for legacy trigger' do - create(:ci_trigger, owner: nil, project: @project) - visit project_settings_ci_cd_path(@project) - - # See if trigger without owner (i.e. legacy) shows "legacy" badge and is editable - expect(page.find('.triggers-list')).to have_content 'legacy' - expect(page.find('.triggers-list')).to have_selector('a[title="Edit"]') - end - - it 'show "invalid" badge for trigger with owner having insufficient permissions' do - create(:ci_trigger, owner: guest_user, project: @project, description: trigger_title) - visit project_settings_ci_cd_path(@project) - - # See if trigger without owner (i.e. legacy) shows "legacy" badge and is non-editable - expect(page.find('.triggers-list')).to have_content 'invalid' - expect(page.find('.triggers-list')).not_to have_selector('a[title="Edit"]') - end - - it 'do not show "Edit" or full token for not owned trigger' do - # Create trigger with user different from current_user - create(:ci_trigger, owner: user2, project: @project, description: trigger_title) - visit project_settings_ci_cd_path(@project) - - # See if trigger not owned by current_user shows only first few token chars and doesn't have copy-to-clipboard button - expect(page.find('.triggers-list')).to have_content(@project.triggers.first.token[0..3]) - expect(page.find('.triggers-list')).not_to have_selector('button.btn-clipboard') - - # See if trigger owner name doesn't match with current_user and trigger is non-editable - expect(page.find('.triggers-list .trigger-owner')).not_to have_content user.name - expect(page.find('.triggers-list')).not_to have_selector('a[title="Edit"]') - end - - it 'show "Edit" and full token for owned trigger' do - create(:ci_trigger, owner: user, project: @project, description: trigger_title) - visit project_settings_ci_cd_path(@project) - - # See if trigger shows full token and has copy-to-clipboard button - expect(page.find('.triggers-list')).to have_content @project.triggers.first.token - expect(page.find('.triggers-list')).to have_selector('button.btn-clipboard') - - # See if trigger owner name matches with current_user and is editable - expect(page.find('.triggers-list .trigger-owner')).to have_content user.name - expect(page.find('.triggers-list')).to have_selector('a[title="Edit"]') - end - end end end diff --git a/spec/features/users/signup_spec.rb b/spec/features/users/signup_spec.rb index 3b19bd423a4c5ba3991af054cbf60620bc6aa0cf..30f298b1fc3e57bbab10b1f176d38a3a76300b2b 100644 --- a/spec/features/users/signup_spec.rb +++ b/spec/features/users/signup_spec.rb @@ -123,50 +123,6 @@ shared_examples 'Signup' do end end - describe 'user\'s full name validation', :js do - before do - if Gitlab::Experimentation.enabled?(:signup_flow) - user = create(:user, role: nil) - sign_in(user) - visit users_sign_up_welcome_path - @user_name_field = 'user_name' - else - visit new_user_registration_path - @user_name_field = 'new_user_name' - end - end - - it 'does not show an error border if the user\'s fullname length is not longer than 128 characters' do - fill_in @user_name_field, with: 'u' * 128 - - expect(find('.name')).not_to have_css '.gl-field-error-outline' - end - - it 'shows an error border if the user\'s fullname contains an emoji' do - simulate_input("##{@user_name_field}", 'Ehsan 🦋') - - expect(find('.name')).to have_css '.gl-field-error-outline' - end - - it 'shows an error border if the user\'s fullname is longer than 128 characters' do - fill_in @user_name_field, with: 'n' * 129 - - expect(find('.name')).to have_css '.gl-field-error-outline' - end - - it 'shows an error message if the user\'s fullname is longer than 128 characters' do - fill_in @user_name_field, with: 'n' * 129 - - expect(page).to have_content("Name is too long (maximum is 128 characters).") - end - - it 'shows an error message if the username contains emojis' do - simulate_input("##{@user_name_field}", 'Ehsan 🦋') - - expect(page).to have_content("Invalid input, please avoid emojis") - end - end - context 'with no errors' do context 'when sending confirmation email' do before do @@ -184,7 +140,10 @@ shared_examples 'Signup' do fill_in 'new_user_username', with: new_user.username fill_in 'new_user_email', with: new_user.email - unless Gitlab::Experimentation.enabled?(:signup_flow) + if Gitlab::Experimentation.enabled?(:signup_flow) + fill_in 'new_user_first_name', with: new_user.first_name + fill_in 'new_user_last_name', with: new_user.last_name + else fill_in 'new_user_name', with: new_user.name fill_in 'new_user_email_confirmation', with: new_user.email end @@ -209,7 +168,10 @@ shared_examples 'Signup' do fill_in 'new_user_username', with: new_user.username fill_in 'new_user_email', with: new_user.email - unless Gitlab::Experimentation.enabled?(:signup_flow) + if Gitlab::Experimentation.enabled?(:signup_flow) + fill_in 'new_user_first_name', with: new_user.first_name + fill_in 'new_user_last_name', with: new_user.last_name + else fill_in 'new_user_name', with: new_user.name fill_in 'new_user_email_confirmation', with: new_user.email end @@ -235,7 +197,10 @@ shared_examples 'Signup' do fill_in 'new_user_username', with: new_user.username fill_in 'new_user_email', with: new_user.email - unless Gitlab::Experimentation.enabled?(:signup_flow) + if Gitlab::Experimentation.enabled?(:signup_flow) + fill_in 'new_user_first_name', with: new_user.first_name + fill_in 'new_user_last_name', with: new_user.last_name + else fill_in 'new_user_name', with: new_user.name fill_in 'new_user_email_confirmation', with: new_user.email.capitalize end @@ -263,7 +228,10 @@ shared_examples 'Signup' do fill_in 'new_user_username', with: new_user.username fill_in 'new_user_email', with: new_user.email - unless Gitlab::Experimentation.enabled?(:signup_flow) + if Gitlab::Experimentation.enabled?(:signup_flow) + fill_in 'new_user_first_name', with: new_user.first_name + fill_in 'new_user_last_name', with: new_user.last_name + else fill_in 'new_user_name', with: new_user.name fill_in 'new_user_email_confirmation', with: new_user.email end @@ -287,7 +255,10 @@ shared_examples 'Signup' do visit new_user_registration_path - unless Gitlab::Experimentation.enabled?(:signup_flow) + if Gitlab::Experimentation.enabled?(:signup_flow) + fill_in 'new_user_first_name', with: new_user.first_name + fill_in 'new_user_last_name', with: new_user.last_name + else fill_in 'new_user_name', with: new_user.name end @@ -313,7 +284,10 @@ shared_examples 'Signup' do visit new_user_registration_path - unless Gitlab::Experimentation.enabled?(:signup_flow) + if Gitlab::Experimentation.enabled?(:signup_flow) + fill_in 'new_user_first_name', with: new_user.first_name + fill_in 'new_user_last_name', with: new_user.last_name + else fill_in 'new_user_name', with: new_user.name end @@ -338,7 +312,10 @@ shared_examples 'Signup' do fill_in 'new_user_username', with: new_user.username fill_in 'new_user_email', with: new_user.email - unless Gitlab::Experimentation.enabled?(:signup_flow) + if Gitlab::Experimentation.enabled?(:signup_flow) + fill_in 'new_user_first_name', with: new_user.first_name + fill_in 'new_user_last_name', with: new_user.last_name + else fill_in 'new_user_name', with: new_user.name fill_in 'new_user_email_confirmation', with: new_user.email end @@ -357,7 +334,10 @@ shared_examples 'Signup' do fill_in 'new_user_username', with: new_user.username fill_in 'new_user_email', with: new_user.email - unless Gitlab::Experimentation.enabled?(:signup_flow) + if Gitlab::Experimentation.enabled?(:signup_flow) + fill_in 'new_user_first_name', with: new_user.first_name + fill_in 'new_user_last_name', with: new_user.last_name + else fill_in 'new_user_name', with: new_user.name fill_in 'new_user_email_confirmation', with: new_user.email end @@ -394,7 +374,10 @@ shared_examples 'Signup' do fill_in 'new_user_username', with: new_user.username fill_in 'new_user_email', with: new_user.email - unless Gitlab::Experimentation.enabled?(:signup_flow) + if Gitlab::Experimentation.enabled?(:signup_flow) + fill_in 'new_user_first_name', with: new_user.first_name + fill_in 'new_user_last_name', with: new_user.last_name + else fill_in 'new_user_name', with: new_user.name fill_in 'new_user_email_confirmation', with: new_user.email end @@ -412,6 +395,44 @@ shared_examples 'Signup' do end end +shared_examples 'Signup name validation' do |field, max_length| + before do + visit new_user_registration_path + end + + describe "#{field} validation", :js do + it "does not show an error border if the user's fullname length is not longer than #{max_length} characters" do + fill_in field, with: 'u' * max_length + + expect(find('.name')).not_to have_css '.gl-field-error-outline' + end + + it 'shows an error border if the user\'s fullname contains an emoji' do + simulate_input("##{field}", 'Ehsan 🦋') + + expect(find('.name')).to have_css '.gl-field-error-outline' + end + + it "shows an error border if the user\'s fullname is longer than #{max_length} characters" do + fill_in field, with: 'n' * (max_length + 1) + + expect(find('.name')).to have_css '.gl-field-error-outline' + end + + it "shows an error message if the user\'s fullname is longer than #{max_length} characters" do + fill_in field, with: 'n' * (max_length + 1) + + expect(page).to have_content("Name is too long (maximum is #{max_length} characters).") + end + + it 'shows an error message if the username contains emojis' do + simulate_input("##{field}", 'Ehsan 🦋') + + expect(page).to have_content("Invalid input, please avoid emojis") + end + end +end + describe 'With original flow' do before do stub_experiment(signup_flow: false) @@ -419,6 +440,7 @@ describe 'With original flow' do end it_behaves_like 'Signup' + it_behaves_like 'Signup name validation', 'new_user_name', 255 end describe 'With experimental flow' do @@ -428,11 +450,15 @@ describe 'With experimental flow' do end it_behaves_like 'Signup' + it_behaves_like 'Signup name validation', 'new_user_first_name', 127 + it_behaves_like 'Signup name validation', 'new_user_last_name', 127 describe 'when role is required' do it 'after registering, it redirects to step 2 of the signup process, sets the name and role and then redirects to the original requested url' do new_user = build_stubbed(:user) visit new_user_registration_path + fill_in 'new_user_first_name', with: new_user.first_name + fill_in 'new_user_last_name', with: new_user.last_name fill_in 'new_user_username', with: new_user.username fill_in 'new_user_email', with: new_user.email fill_in 'new_user_password', with: new_user.password @@ -441,13 +467,11 @@ describe 'With experimental flow' do expect(page).to have_current_path(users_sign_up_welcome_path) - fill_in 'user_name', with: 'New name' select 'Software Developer', from: 'user_role' choose 'user_setup_for_company_true' click_button 'Get started!' new_user = User.find_by_username(new_user.username) - expect(new_user.name).to eq 'New name' expect(new_user.software_developer_role?).to be_truthy expect(new_user.setup_for_company).to be_truthy expect(page).to have_current_path(new_project_path) diff --git a/spec/finders/branches_finder_spec.rb b/spec/finders/branches_finder_spec.rb index 70b5da0cc3c2186fbb1e9a0cfc92e262c6ed3cb5..5f75ff8c6ff9ce9995b738eface15546f521bd10 100644 --- a/spec/finders/branches_finder_spec.rb +++ b/spec/finders/branches_finder_spec.rb @@ -66,7 +66,7 @@ describe BranchesFinder do end it 'filters branches by provided names' do - branches_finder = described_class.new(repository, { names: ['fix', 'csv', 'lfs', 'does-not-exist'] }) + branches_finder = described_class.new(repository, { names: %w[fix csv lfs does-not-exist] }) result = branches_finder.execute diff --git a/spec/finders/clusters/knative_services_finder_spec.rb b/spec/finders/clusters/knative_services_finder_spec.rb index 7ad64cc3bca0bacb033ae0d1f14ef7cb6e446f59..57dbead792102012609229df6a087cc41b291fd8 100644 --- a/spec/finders/clusters/knative_services_finder_spec.rb +++ b/spec/finders/clusters/knative_services_finder_spec.rb @@ -6,9 +6,9 @@ describe Clusters::KnativeServicesFinder do include KubernetesHelpers include ReactiveCachingHelpers - let(:cluster) { create(:cluster, :project, :provided_by_gcp) } + let(:project) { create(:project, :repository) } + let(:cluster) { create(:cluster, :project, :provided_by_gcp, projects: [project]) } let(:service) { environment.deployment_platform } - let(:project) { cluster.cluster_project.project } let(:environment) { create(:environment, project: project) } let!(:deployment) { create(:deployment, :success, environment: environment, cluster: cluster) } let(:namespace) do diff --git a/spec/finders/deployments_finder_spec.rb b/spec/finders/deployments_finder_spec.rb index be35a705b0d2d1773e077ae7d3e887b034ecbd85..b20c7e5a8a5bbda1fc40d45f78f514a15762d3a1 100644 --- a/spec/finders/deployments_finder_spec.rb +++ b/spec/finders/deployments_finder_spec.rb @@ -5,7 +5,7 @@ require 'spec_helper' describe DeploymentsFinder do subject { described_class.new(project, params).execute } - let(:project) { create(:project, :public, :repository) } + let(:project) { create(:project, :public, :test_repo) } let(:params) { {} } describe "#execute" do @@ -25,6 +25,42 @@ describe DeploymentsFinder do is_expected.to match_array([deployment_1]) end end + + context 'when the environment name is specified' do + let!(:environment1) { create(:environment, project: project) } + let!(:environment2) { create(:environment, project: project) } + let!(:deployment1) do + create(:deployment, project: project, environment: environment1) + end + + let!(:deployment2) do + create(:deployment, project: project, environment: environment2) + end + + let(:params) { { environment: environment1.name } } + + it 'returns deployments for the given environment' do + is_expected.to match_array([deployment1]) + end + end + + context 'when the deployment status is specified' do + let!(:deployment1) { create(:deployment, :success, project: project) } + let!(:deployment2) { create(:deployment, :failed, project: project) } + let(:params) { { status: 'success' } } + + it 'returns deployments for the given environment' do + is_expected.to match_array([deployment1]) + end + end + + context 'when using an invalid deployment status' do + let(:params) { { status: 'kittens' } } + + it 'raises ArgumentError' do + expect { subject }.to raise_error(ArgumentError) + end + end end describe 'ordering' do @@ -34,7 +70,7 @@ describe DeploymentsFinder do let!(:deployment_1) { create(:deployment, :success, project: project, iid: 11, ref: 'master', created_at: 2.days.ago, updated_at: Time.now) } let!(:deployment_2) { create(:deployment, :success, project: project, iid: 12, ref: 'feature', created_at: 1.day.ago, updated_at: 2.hours.ago) } - let!(:deployment_3) { create(:deployment, :success, project: project, iid: 8, ref: 'patch', created_at: Time.now, updated_at: 1.hour.ago) } + let!(:deployment_3) { create(:deployment, :success, project: project, iid: 8, ref: 'video', created_at: Time.now, updated_at: 1.hour.ago) } where(:order_by, :sort, :ordered_deployments) do 'created_at' | 'asc' | [:deployment_1, :deployment_2, :deployment_3] diff --git a/spec/finders/environments_finder_spec.rb b/spec/finders/environments_finder_spec.rb index 69687eaa99fbce532188f21945cc7c3f8e809acd..7100376478afe4510ce00a6e508e58b7663c9c89 100644 --- a/spec/finders/environments_finder_spec.rb +++ b/spec/finders/environments_finder_spec.rb @@ -13,17 +13,22 @@ describe EnvironmentsFinder do end context 'tagged deployment' do + let(:environment_two) { create(:environment, project: project) } + # Environments need to include commits, so rewind two commits to fit + let(:commit) { project.commit('HEAD~2') } + before do - create(:deployment, :success, environment: environment, ref: 'v1.1.0', tag: true, sha: project.commit.id) + create(:deployment, :success, environment: environment, ref: 'v1.0.0', tag: true, sha: project.commit.id) + create(:deployment, :success, environment: environment_two, ref: 'v1.1.0', tag: true, sha: project.commit('HEAD~1').id) end it 'returns environment when with_tags is set' do - expect(described_class.new(project, user, ref: 'master', commit: project.commit, with_tags: true).execute) - .to contain_exactly(environment) + expect(described_class.new(project, user, ref: 'master', commit: commit, with_tags: true).execute) + .to contain_exactly(environment, environment_two) end it 'does not return environment when no with_tags is set' do - expect(described_class.new(project, user, ref: 'master', commit: project.commit).execute) + expect(described_class.new(project, user, ref: 'master', commit: commit).execute) .to be_empty end @@ -31,6 +36,21 @@ describe EnvironmentsFinder do expect(described_class.new(project, user, ref: 'master', commit: project.commit('feature')).execute) .to be_empty end + + it 'returns environment when with_tags is set' do + expect(described_class.new(project, user, ref: 'master', commit: commit, with_tags: true).execute) + .to contain_exactly(environment, environment_two) + end + + # We expect two Gitaly calls: FindCommit, CommitIsAncestor + # This tests to ensure we don't call one CommitIsAncestor per environment + it 'only calls Gitaly twice when multiple environments are present', :request_store do + expect do + result = described_class.new(project, user, ref: 'master', commit: commit, with_tags: true, find_latest: true).execute + + expect(result).to contain_exactly(environment_two) + end.to change { Gitlab::GitalyClient.get_request_count }.by(2) + end end context 'branch deployment' do diff --git a/spec/finders/events_finder_spec.rb b/spec/finders/events_finder_spec.rb index 848030262cd6cfa62861e33071ffdc319c723637..5c28b31e8c846bb557c21f9b4a3cb7339491929f 100644 --- a/spec/finders/events_finder_spec.rb +++ b/spec/finders/events_finder_spec.rb @@ -5,8 +5,10 @@ require 'spec_helper' describe EventsFinder do let(:user) { create(:user) } let(:other_user) { create(:user) } + let(:project1) { create(:project, :private, creator_id: user.id, namespace: user.namespace) } let(:project2) { create(:project, :private, creator_id: user.id, namespace: user.namespace) } + let(:closed_issue) { create(:closed_issue, project: project1, author: user) } let(:opened_merge_request) { create(:merge_request, source_project: project2, author: user) } let!(:closed_issue_event) { create(:event, project: project1, author: user, target: closed_issue, action: Event::CLOSED, created_at: Date.new(2016, 12, 30)) } @@ -15,6 +17,8 @@ describe EventsFinder do let(:opened_merge_request2) { create(:merge_request, source_project: project2, author: user) } let!(:closed_issue_event2) { create(:event, project: project1, author: user, target: closed_issue, action: Event::CLOSED, created_at: Date.new(2016, 2, 2)) } let!(:opened_merge_request_event2) { create(:event, project: project2, author: user, target: opened_merge_request, action: Event::CREATED, created_at: Date.new(2017, 2, 2)) } + let(:opened_merge_request3) { create(:merge_request, source_project: project1, author: other_user) } + let!(:other_developer_event) { create(:event, project: project1, author: other_user, target: opened_merge_request3, action: Event::CREATED) } let(:public_project) { create(:project, :public, creator_id: user.id, namespace: user.namespace) } let(:confidential_issue) { create(:closed_issue, confidential: true, project: public_project, author: user) } @@ -55,6 +59,28 @@ describe EventsFinder do end end + context 'dashboard events' do + before do + project1.add_developer(other_user) + end + + context 'scope is `all`' do + it 'includes activity of other users' do + events = described_class.new(source: user, current_user: user, scope: 'all').execute + + expect(events).to include(other_developer_event) + end + end + + context 'scope is not `all`' do + it 'does not include activity of other users' do + events = described_class.new(source: user, current_user: user, scope: '').execute + + expect(events).not_to include(other_developer_event) + end + end + end + context 'when targeting a project' do it 'returns project events between specified dates filtered on action and type' do events = described_class.new(source: project1, current_user: user, action: 'closed', target_type: 'issue', after: Date.new(2016, 12, 1), before: Date.new(2017, 1, 1)).execute diff --git a/spec/finders/group_members_finder_spec.rb b/spec/finders/group_members_finder_spec.rb index f161a1df9c36c3f0c5b806ea1b9526a33a095b03..34649097f701e7b43704577201cf2853dde636cd 100644 --- a/spec/finders/group_members_finder_spec.rb +++ b/spec/finders/group_members_finder_spec.rb @@ -10,6 +10,7 @@ describe GroupMembersFinder, '#execute' do let(:user2) { create(:user) } let(:user3) { create(:user) } let(:user4) { create(:user) } + let(:user5) { create(:user, :two_factor_via_otp) } it 'returns members for top-level group' do member1 = group.add_maintainer(user1) @@ -56,6 +57,14 @@ describe GroupMembersFinder, '#execute' do expect(result.to_a).to match_array([member1]) end + it 'does not return nil if `inherited only` relation is requested on root group' do + group.add_developer(user2) + + result = described_class.new(group).execute(include_relations: [:inherited]) + + expect(result).not_to be_nil + end + it 'returns members for descendant groups if requested' do member1 = group.add_maintainer(user2) member2 = group.add_maintainer(user1) @@ -67,4 +76,56 @@ describe GroupMembersFinder, '#execute' do expect(result.to_a).to match_array([member1, member2, member3, member4]) end + + it 'returns searched members if requested' do + group.add_maintainer(user2) + group.add_developer(user3) + member = group.add_maintainer(user1) + + result = described_class.new(group).execute(params: { search: user1.name }) + + expect(result.to_a).to match_array([member]) + end + + it 'returns nothing if search only in inherited relation' do + group.add_maintainer(user2) + group.add_developer(user3) + group.add_maintainer(user1) + + result = described_class.new(group).execute(include_relations: [:inherited], params: { search: user1.name }) + + expect(result.to_a).to match_array([]) + end + + it 'returns searched member only from nested_group if search only in inherited relation' do + group.add_maintainer(user2) + group.add_developer(user3) + nested_group.add_maintainer(create(:user, name: user1.name)) + member = group.add_maintainer(user1) + + result = described_class.new(nested_group).execute(include_relations: [:inherited], params: { search: member.user.name }) + + expect(result.to_a).to contain_exactly(member) + end + + it 'returns members with two-factor auth if requested by owner' do + group.add_owner(user2) + group.add_maintainer(user1) + member = group.add_maintainer(user5) + + result = described_class.new(group, user2).execute(params: { two_factor: 'enabled' }) + + expect(result.to_a).to contain_exactly(member) + end + + it 'returns members without two-factor auth if requested by owner' do + member1 = group.add_owner(user2) + member2 = group.add_maintainer(user1) + member_with_2fa = group.add_maintainer(user5) + + result = described_class.new(group, user2).execute(params: { two_factor: 'disabled' }) + + expect(result.to_a).not_to include(member_with_2fa) + expect(result.to_a).to match_array([member1, member2]) + end end diff --git a/spec/finders/issues_finder_spec.rb b/spec/finders/issues_finder_spec.rb index c52ee89006bcb351c5544f3563fb6ed15522d9c5..056795a50d0c4fc7fdae3dca2d2d6319541c23ec 100644 --- a/spec/finders/issues_finder_spec.rb +++ b/spec/finders/issues_finder_spec.rb @@ -435,9 +435,7 @@ describe IssuesFinder do let(:params) { { label_name: described_class::FILTER_ANY } } it 'returns issues that have one or more label' do - 2.times do - create(:label_link, label: create(:label, project: project2), target: issue3) - end + create_list(:label_link, 2, label: create(:label, project: project2), target: issue3) expect(issues).to contain_exactly(issue2, issue3) end diff --git a/spec/finders/keys_finder_spec.rb b/spec/finders/keys_finder_spec.rb index f80abdcdb386be8fdfc9ba105ea004e4fba7a538..7605d066ddf61a327dc21fce716041a763f92360 100644 --- a/spec/finders/keys_finder_spec.rb +++ b/spec/finders/keys_finder_spec.rb @@ -73,7 +73,15 @@ describe KeysFinder do end context 'with valid fingerprints' do - context 'with valid MD5 params' do + let!(:deploy_key) do + create(:deploy_key, + user: user, + key: 'ssh-rsa AAAAB3NzaC1yc2EAAAABJQAAAIEAiPWx6WM4lhHNedGfBpPJNPpZ7yKu+dnn1SJejgt1017k6YjzGGphH2TUxwKzxcKDKKezwkpfnxPkSMkuEspGRt/aZZ9wa++Oi7Qkr8prgHc4soW6NUlfDzpvZK2H5E7eQaSeP3SAwGmQKUFHCddNaP0L+hM7zhFNzjFvpaMgJw0=', + fingerprint: '8a:4a:12:92:0b:50:47:02:d4:5a:8e:a9:44:4e:08:b4', + fingerprint_sha256: '4DPHOVNh53i9dHb5PpY2vjfyf5qniTx1/pBFPoZLDdk') + end + + context 'personal key with valid MD5 params' do context 'with an existent fingerprint' do before do params[:fingerprint] = 'ba:81:59:68:d7:6c:cd:02:02:bf:6a:9b:55:4e:af:d1' @@ -85,6 +93,17 @@ describe KeysFinder do end end + context 'deploy key with an existent fingerprint' do + before do + params[:fingerprint] = '8a:4a:12:92:0b:50:47:02:d4:5a:8e:a9:44:4e:08:b4' + end + + it 'returns the key' do + expect(subject).to eq(deploy_key) + expect(subject.user).to eq(user) + end + end + context 'with a non-existent fingerprint' do before do params[:fingerprint] = 'bb:81:59:68:d7:6c:cd:02:02:bf:6a:9b:55:4e:af:d2' @@ -96,7 +115,7 @@ describe KeysFinder do end end - context 'with valid SHA256 params' do + context 'personal key with valid SHA256 params' do context 'with an existent fingerprint' do before do params[:fingerprint] = 'SHA256:nUhzNyftwADy8AH3wFY31tAKs7HufskYTte2aXo/lCg' @@ -108,6 +127,17 @@ describe KeysFinder do end end + context 'deploy key with an existent fingerprint' do + before do + params[:fingerprint] = 'SHA256:4DPHOVNh53i9dHb5PpY2vjfyf5qniTx1/pBFPoZLDdk' + end + + it 'returns key' do + expect(subject).to eq(deploy_key) + expect(subject.user).to eq(user) + end + end + context 'with a non-existent fingerprint' do before do params[:fingerprint] = 'SHA256:xTjuFqftwADy8AH3wFY31tAKs7HufskYTte2aXi/mNp' diff --git a/spec/finders/merge_requests_finder_spec.rb b/spec/finders/merge_requests_finder_spec.rb index bc85a622119394dbcb501d7f9a6b04638d4211d4..849387b72bd50270c20a7854ff600dea811af482 100644 --- a/spec/finders/merge_requests_finder_spec.rb +++ b/spec/finders/merge_requests_finder_spec.rb @@ -166,6 +166,38 @@ describe MergeRequestsFinder do expect(scalar_params).to include(:wip, :assignee_id) end + + context 'filter by deployment' do + let_it_be(:project_with_repo) { create(:project, :repository) } + + it 'returns the relevant merge requests' do + deployment1 = create( + :deployment, + project: project_with_repo, + sha: project_with_repo.commit.id, + merge_requests: [merge_request1, merge_request2] + ) + create( + :deployment, + project: project_with_repo, + sha: project_with_repo.commit.id, + merge_requests: [merge_request3] + ) + params = { deployment_id: deployment1.id } + merge_requests = described_class.new(user, params).execute + + expect(merge_requests).to contain_exactly(merge_request1, merge_request2) + end + + context 'when a deployment does not contain any merge requests' do + it 'returns an empty result' do + params = { deployment_id: create(:deployment, project: project_with_repo, sha: project_with_repo.commit.sha).id } + merge_requests = described_class.new(user, params).execute + + expect(merge_requests).to be_empty + end + end + end end context 'assignee filtering' do diff --git a/spec/finders/pipelines_finder_spec.rb b/spec/finders/pipelines_finder_spec.rb index c8a4ea799c326a4531e2a3a92d31ff9d05ee189a..1dbf949111884886343e8119a53ff6d95f68afac 100644 --- a/spec/finders/pipelines_finder_spec.rb +++ b/spec/finders/pipelines_finder_spec.rb @@ -64,6 +64,19 @@ describe PipelinesFinder do end end + context 'when project has child pipelines' do + let!(:parent_pipeline) { create(:ci_pipeline, project: project) } + let!(:child_pipeline) { create(:ci_pipeline, project: project, source: :parent_pipeline) } + + let!(:pipeline_source) do + create(:ci_sources_pipeline, pipeline: child_pipeline, source_pipeline: parent_pipeline) + end + + it 'filters out child pipelines and show only the parents' do + is_expected.to eq([parent_pipeline]) + end + end + HasStatus::AVAILABLE_STATUSES.each do |target| context "when status is #{target}" do let(:params) { { status: target } } diff --git a/spec/finders/projects/serverless/functions_finder_spec.rb b/spec/finders/projects/serverless/functions_finder_spec.rb index 589e4000d46578d26d8d313aa0f45c90da3515a0..d5644daebab9b83ff3ba6d48a29fc3b1e9cc3436 100644 --- a/spec/finders/projects/serverless/functions_finder_spec.rb +++ b/spec/finders/projects/serverless/functions_finder_spec.rb @@ -8,9 +8,9 @@ describe Projects::Serverless::FunctionsFinder do include ReactiveCachingHelpers let(:user) { create(:user) } - let(:cluster) { create(:cluster, :project, :provided_by_gcp) } + let(:project) { create(:project, :repository) } + let(:cluster) { create(:cluster, :project, :provided_by_gcp, projects: [project]) } let(:service) { cluster.platform_kubernetes } - let(:project) { cluster.project } let(:environment) { create(:environment, project: project) } let!(:deployment) { create(:deployment, :success, environment: environment, cluster: cluster) } let(:knative_services_finder) { environment.knative_services_finder } @@ -108,7 +108,7 @@ describe Projects::Serverless::FunctionsFinder do let(:finder) { described_class.new(project) } before do - allow(Prometheus::AdapterService).to receive(:new).and_return(double(prometheus_adapter: prometheus_adapter)) + allow(Gitlab::Prometheus::Adapter).to receive(:new).and_return(double(prometheus_adapter: prometheus_adapter)) allow(prometheus_adapter).to receive(:query).and_return(prometheus_empty_body('matrix')) end diff --git a/spec/finders/sentry_issue_finder_spec.rb b/spec/finders/sentry_issue_finder_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..5535eb8c2144836510fe9a75d0432164056c08fd --- /dev/null +++ b/spec/finders/sentry_issue_finder_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe SentryIssueFinder do + let(:user) { create(:user) } + let(:project) { create(:project, :repository) } + let(:issue) { create(:issue, project: project) } + let(:sentry_issue) { create(:sentry_issue, issue: issue) } + + let(:finder) { described_class.new(project, current_user: user) } + + describe '#execute' do + let(:identifier) { sentry_issue.sentry_issue_identifier } + + subject { finder.execute(identifier) } + + context 'when the user is not part of the project' do + it { is_expected.to be_nil } + end + + context 'when the user is a project developer' do + before do + project.add_developer(user) + end + + it { is_expected.to eq(sentry_issue) } + + context 'when identifier is incorrect' do + let(:identifier) { 1234 } + + it { is_expected.to be_nil } + end + + context 'when accessing another projects identifier' do + let(:second_project) { create(:project) } + let(:second_issue) { create(:issue, project: second_project) } + let(:second_sentry_issue) { create(:sentry_issue, issue: second_issue) } + + let(:identifier) { second_sentry_issue.sentry_issue_identifier } + + it { is_expected.to be_nil } + end + end + end +end diff --git a/spec/finders/todos_finder_spec.rb b/spec/finders/todos_finder_spec.rb index a837e7af251d2db6454cec784894c403046ccb5c..a35c3a954e74f794ae061c135b9653006b644e61 100644 --- a/spec/finders/todos_finder_spec.rb +++ b/spec/finders/todos_finder_spec.rb @@ -219,7 +219,7 @@ describe TodosFinder do end it "sorts by priority" do - project_2 = create(:project) + project_2 = create(:project) label_1 = create(:label, title: 'label_1', project: project, priority: 1) label_2 = create(:label, title: 'label_2', project: project, priority: 2) diff --git a/spec/fixtures/api/schemas/cluster_status.json b/spec/fixtures/api/schemas/cluster_status.json index f978baa202670997474c044d5dd299fe79ad15ce..29c56b5c820d4dc488c7ee25fa8b23f08e86d9a0 100644 --- a/spec/fixtures/api/schemas/cluster_status.json +++ b/spec/fixtures/api/schemas/cluster_status.json @@ -35,9 +35,9 @@ "external_ip": { "type": ["string", "null"] }, "external_hostname": { "type": ["string", "null"] }, "hostname": { "type": ["string", "null"] }, - "kibana_hostname": { "type": ["string", "null"] }, "email": { "type": ["string", "null"] }, "stack": { "type": ["string", "null"] }, + "modsecurity_enabled": { "type": ["boolean", "null"] }, "update_available": { "type": ["boolean", "null"] }, "can_uninstall": { "type": "boolean" } }, diff --git a/spec/fixtures/api/schemas/entities/issue_board.json b/spec/fixtures/api/schemas/entities/issue_board.json index 7cb65e1f2f5ccdbc8675e8a663f8ef2c87820ab8..09f66813c956cd681ade742ae0394b0dcc13d161 100644 --- a/spec/fixtures/api/schemas/entities/issue_board.json +++ b/spec/fixtures/api/schemas/entities/issue_board.json @@ -36,7 +36,8 @@ "real_path": { "type": "string" }, "issue_sidebar_endpoint": { "type": "string" }, "toggle_subscription_endpoint": { "type": "string" }, - "assignable_labels_endpoint": { "type": "string" } + "assignable_labels_endpoint": { "type": "string" }, + "blocked": { "type": "boolean" } }, "additionalProperties": false } diff --git a/spec/fixtures/api/schemas/error_tracking/error_detailed.json b/spec/fixtures/api/schemas/error_tracking/error_detailed.json index 2a1cd2c03e077d6ffb8a6c758d3b16424dcdbcf2..2b6580e39f7891bd866d845ae1a5cfd7094f5344 100644 --- a/spec/fixtures/api/schemas/error_tracking/error_detailed.json +++ b/spec/fixtures/api/schemas/error_tracking/error_detailed.json @@ -1,10 +1,11 @@ { "type": "object", - "required" : [ + "required": [ "external_url", "external_base_url", "last_seen", "message", + "tags", "type", "title", "project_id", @@ -17,31 +18,46 @@ "first_release_last_commit", "last_release_last_commit", "first_release_short_version", - "last_release_short_version" + "last_release_short_version", + "gitlab_commit" ], - "properties" : { - "id": { "type": "string"}, + "properties": { + "id": { "type": "string" }, "first_seen": { "type": "string", "format": "date-time" }, "last_seen": { "type": "string", "format": "date-time" }, "type": { "type": "string" }, "message": { "type": "string" }, "culprit": { "type": "string" }, - "count": { "type": "integer"}, + "count": { "type": "integer" }, "external_url": { "type": "string" }, "external_base_url": { "type": "string" }, - "user_count": { "type": "integer"}, - "title": { "type": "string"}, - "project_id": { "type": "string"}, - "project_name": { "type": "string"}, - "project_slug": { "type": "string"}, - "short_id": { "type": "string"}, - "status": { "type": "string"}, - "frequency": { "type": "array"}, + "user_count": { "type": "integer" }, + "tags": { + "type": "object", + "required": ["level", "logger"], + "properties": { + "level": { + "type": "string" + }, + "logger": { + "type": "string" + } + } + }, + "title": { "type": "string" }, + "project_id": { "type": "string" }, + "project_name": { "type": "string" }, + "project_slug": { "type": "string" }, + "short_id": { "type": "string" }, + "status": { "type": "string" }, + "frequency": { "type": "array" }, "gitlab_issue": { "type": ["string", "null"] }, "first_release_last_commit": { "type": ["string", "null"] }, "last_release_last_commit": { "type": ["string", "null"] }, "first_release_short_version": { "type": ["string", "null"] }, - "last_release_short_version": { "type": ["string", "null"] } + "last_release_short_version": { "type": ["string", "null"] }, + "gitlab_commit": { "type": ["string", "null"] }, + "gitlab_commit_path": { "type": ["string", "null"] } }, "additionalProperties": false } diff --git a/spec/fixtures/api/schemas/error_tracking/update_issue.json b/spec/fixtures/api/schemas/error_tracking/update_issue.json new file mode 100644 index 0000000000000000000000000000000000000000..72514ce647df968fe3c391aaf78b26adb9c97557 --- /dev/null +++ b/spec/fixtures/api/schemas/error_tracking/update_issue.json @@ -0,0 +1,16 @@ +{ + "type": "object", + "required" : [ + "result" + ], + "properties" : { + "result": { + "type": "object", + "properties": { + "status": { "type": "string" }, + "updated": { "type": "boolean" } + } + } + }, + "additionalProperties": false +} diff --git a/spec/fixtures/api/schemas/public_api/v4/issue.json b/spec/fixtures/api/schemas/public_api/v4/issue.json index 147f53239e05ac89e1552c680800387244a69dbe..bf1b4a06f0b954dc4e6b159f5b5fb46a8aa524f9 100644 --- a/spec/fixtures/api/schemas/public_api/v4/issue.json +++ b/spec/fixtures/api/schemas/public_api/v4/issue.json @@ -84,6 +84,11 @@ "total_time_spent": { "type": "integer" }, "human_time_estimate": { "type": ["string", "null"] }, "human_total_time_spent": { "type": ["string", "null"] } + }, + "references": { + "short": {"type": "string"}, + "relative": {"type": "string"}, + "full": {"type": "string"} } }, "required": [ diff --git a/spec/fixtures/api/schemas/public_api/v4/label_basic.json b/spec/fixtures/api/schemas/public_api/v4/label_basic.json index 37bbdcb14fe09b8616c1d43be5e087db3e3de3f2..a501bc2ec568614b531572faaa6ec1d0246bac52 100644 --- a/spec/fixtures/api/schemas/public_api/v4/label_basic.json +++ b/spec/fixtures/api/schemas/public_api/v4/label_basic.json @@ -5,6 +5,7 @@ "name", "color", "description", + "description_html", "text_color" ], "properties": { @@ -15,6 +16,7 @@ "pattern": "^#[0-9A-Fa-f]{3}{1,2}$" }, "description": { "type": ["string", "null"] }, + "description_html": { "type": ["string", "null"] }, "text_color": { "type": "string", "pattern": "^#[0-9A-Fa-f]{3}{1,2}$" diff --git a/spec/fixtures/api/schemas/public_api/v4/merge_request.json b/spec/fixtures/api/schemas/public_api/v4/merge_request.json index a423bf70b6943621cb4550b998b096aea45fb219..3bf1299a1d8986a79dac7eb1cde1b79214a95cd3 100644 --- a/spec/fixtures/api/schemas/public_api/v4/merge_request.json +++ b/spec/fixtures/api/schemas/public_api/v4/merge_request.json @@ -113,7 +113,12 @@ "human_total_time_spent": { "type": ["string", "null"] } }, "allow_collaboration": { "type": ["boolean", "null"] }, - "allow_maintainer_to_push": { "type": ["boolean", "null"] } + "allow_maintainer_to_push": { "type": ["boolean", "null"] }, + "references": { + "short": {"type": "string"}, + "relative": {"type": "string"}, + "full": {"type": "string"} + } }, "required": [ "id", "iid", "project_id", "title", "description", diff --git a/spec/fixtures/api/schemas/public_api/v4/service.json b/spec/fixtures/api/schemas/public_api/v4/service.json new file mode 100644 index 0000000000000000000000000000000000000000..b6f13d1cfe736fc694f86de6d6a547ef73b0cf0d --- /dev/null +++ b/spec/fixtures/api/schemas/public_api/v4/service.json @@ -0,0 +1,24 @@ +{ + "type": "object", + "properties": { + "id": { "type": "integer" }, + "title": { "type": "string" }, + "slug": { "type": "string" }, + "created_at": { "type": "date-time" }, + "updated_at": { "type": "date-time" }, + "active": { "type": "boolean" }, + "commit_events": { "type": "boolean" }, + "push_events": { "type": "boolean" }, + "issues_events": { "type": "boolean" }, + "confidential_issues_events": { "type": "boolean" }, + "merge_requests_events": { "type": "boolean" }, + "tag_push_events": { "type": "boolean" }, + "note_events": { "type": "boolean" }, + "confidential_note_events": { "type": "boolean" }, + "pipeline_events": { "type": "boolean" }, + "wiki_page_events": { "type": "boolean" }, + "job_events": { "type": "boolean" }, + "comment_on_event_enabled": { "type": "boolean" } + }, + "additionalProperties": false +} diff --git a/spec/fixtures/api/schemas/public_api/v4/services.json b/spec/fixtures/api/schemas/public_api/v4/services.json new file mode 100644 index 0000000000000000000000000000000000000000..78c59ecfa102a3378f46dec7549b299797452b1b --- /dev/null +++ b/spec/fixtures/api/schemas/public_api/v4/services.json @@ -0,0 +1,4 @@ +{ + "type": "array", + "items": { "$ref": "service.json" } +} diff --git a/spec/fixtures/emails/envelope_to_header.eml b/spec/fixtures/emails/envelope_to_header.eml new file mode 100644 index 0000000000000000000000000000000000000000..4b6418d4c066343d3fbcc21c8aad85e56f9df1d8 --- /dev/null +++ b/spec/fixtures/emails/envelope_to_header.eml @@ -0,0 +1,32 @@ +Return-Path: <jake@example.com> +Received: from myserver.example.com ([unix socket]) by myserver (Cyrus v2.2.13-Debian-2.2.13-19+squeeze3) with LMTPA; Thu, 13 Jun 2013 17:03:50 -0400 +Received: from mail.example.com (mail.example.com [IPv6:2607:f8b0:4001:c03::234]) by myserver.example.com (8.14.3/8.14.3/Debian-9.4) with ESMTP id r5DL3nFJ016967 (version=TLSv1/SSLv3 cipher=RC4-SHA bits=128 verify=NOT) for <incoming+gitlabhq/gitlabhq@example.com>; Thu, 13 Jun 2013 17:03:50 -0400 +Received: by myserver.example.com with SMTP id f4so21977375iea.25 for <incoming+gitlabhq/gitlabhq@appmail.example.com>; Thu, 13 Jun 2013 14:03:48 -0700 +Received: by 10.0.0.1 with HTTP; Thu, 13 Jun 2013 14:03:48 -0700 +From: "jake@example.com" <jake@example.com> +To: "support@example.com" <support@example.com> +Subject: Insert hilarious subject line here +Date: Tue, 26 Nov 2019 14:22:41 +0000 +Message-ID: <7e2296f83dbf4de388cbf5f56f52c11f@EXDAG29-1.EXCHANGE.INT> +Accept-Language: de-DE, en-US +Content-Language: de-DE +X-MS-Has-Attach: +X-MS-TNEF-Correlator: +x-ms-exchange-transport-fromentityheader: Hosted +x-originating-ip: [62.96.54.178] +Content-Type: multipart/alternative; + boundary="_000_7e2296f83dbf4de388cbf5f56f52c11fEXDAG291EXCHANGEINT_" +MIME-Version: 1.0 +Envelope-To: incoming+gitlabhq/gitlabhq+auth_token@appmail.example.com + +--_000_7e2296f83dbf4de388cbf5f56f52c11fEXDAG291EXCHANGEINT_ +Content-Type: text/plain; charset="iso-8859-1" +Content-Transfer-Encoding: quoted-printable + + + +--_000_7e2296f83dbf4de388cbf5f56f52c11fEXDAG291EXCHANGEINT_ +Content-Type: text/html; charset="iso-8859-1" +Content-Transfer-Encoding: quoted-printable + +Look, a message with some alternate headers! We should really support them. diff --git a/spec/fixtures/emails/forwarded_new_issue.eml b/spec/fixtures/emails/forwarded_new_issue.eml index 258106bb8975e6dfaca9b9609fd1a72e772048d8..e3688697651d21d9e834ae24ee6b6ad8b87e7dce 100644 --- a/spec/fixtures/emails/forwarded_new_issue.eml +++ b/spec/fixtures/emails/forwarded_new_issue.eml @@ -1,13 +1,13 @@ -Delivered-To: incoming+gitlabhq/gitlabhq+auth_token@appmail.adventuretime.ooo -Return-Path: <jake@adventuretime.ooo> -Received: from iceking.adventuretime.ooo ([unix socket]) by iceking (Cyrus v2.2.13-Debian-2.2.13-19+squeeze3) with LMTPA; Thu, 13 Jun 2013 17:03:50 -0400 -Received: from mail-ie0-x234.google.com (mail-ie0-x234.google.com [IPv6:2607:f8b0:4001:c03::234]) by iceking.adventuretime.ooo (8.14.3/8.14.3/Debian-9.4) with ESMTP id r5DL3nFJ016967 (version=TLSv1/SSLv3 cipher=RC4-SHA bits=128 verify=NOT) for <incoming+gitlabhq/gitlabhq@appmail.adventuretime.ooo>; Thu, 13 Jun 2013 17:03:50 -0400 -Received: by mail-ie0-f180.google.com with SMTP id f4so21977375iea.25 for <incoming+gitlabhq/gitlabhq@appmail.adventuretime.ooo>; Thu, 13 Jun 2013 14:03:48 -0700 +Delivered-To: incoming+gitlabhq/gitlabhq+auth_token@appmail.example.com +Return-Path: <jake@example.com> +Received: from iceking.example.com ([unix socket]) by iceking (Cyrus v2.2.13-Debian-2.2.13-19+squeeze3) with LMTPA; Thu, 13 Jun 2013 17:03:50 -0400 +Received: from mail-ie0-x234.google.com (mail-ie0-x234.google.com [IPv6:2607:f8b0:4001:c03::234]) by iceking.example.com (8.14.3/8.14.3/Debian-9.4) with ESMTP id r5DL3nFJ016967 (version=TLSv1/SSLv3 cipher=RC4-SHA bits=128 verify=NOT) for <incoming+gitlabhq/gitlabhq@appmail.example.com>; Thu, 13 Jun 2013 17:03:50 -0400 +Received: by mail-ie0-f180.google.com with SMTP id f4so21977375iea.25 for <incoming+gitlabhq/gitlabhq@appmail.example.com>; Thu, 13 Jun 2013 14:03:48 -0700 Received: by 10.0.0.1 with HTTP; Thu, 13 Jun 2013 14:03:48 -0700 Date: Thu, 13 Jun 2013 17:03:48 -0400 -From: Jake the Dog <jake@adventuretime.ooo> -Delivered-To: support@adventuretime.ooo -To: support@adventuretime.ooo +From: Jake the Dog <jake@example.com> +Delivered-To: support@example.com +To: support@example.com Message-ID: <CADkmRc+rNGAGGbV2iE5p918UVy4UyJqVcXRO2=otppgzduJSg@mail.gmail.com> Subject: New Issue by email Mime-Version: 1.0 diff --git a/spec/fixtures/gitlab/sample_metrics/sample_metric_query_result.yml b/spec/fixtures/gitlab/sample_metrics/sample_metric_query_result.yml index ba074912b3b9d945bd9aa9e093e49a9aa10759fd..ce49f55108240a9cd7c3b511f893605ca57fb7ff 100644 --- a/spec/fixtures/gitlab/sample_metrics/sample_metric_query_result.yml +++ b/spec/fixtures/gitlab/sample_metrics/sample_metric_query_result.yml @@ -1,151 +1,332 @@ --- +30: - metric: {} values: - - - 1573560714.209 - - '0.02361297607421875' - - - 1573560774.209 - - '0.02361297607421875' - - - 1573560834.209 - - '0.02362823486328125' - - - 1573560894.209 - - '0.02361297607421875' - - - 1573560954.209 - - '0.02385711669921875' - - - 1573561014.209 - - '0.02361297607421875' - - - 1573561074.209 - - '0.02361297607421875' - - - 1573561134.209 - - '0.02362060546875' - - - 1573561194.209 - - '0.02362060546875' - - - 1573561254.209 - - '0.02362060546875' - - - 1573561314.209 - - '0.02362060546875' - - - 1573561374.209 - - '0.023624420166015625' - - - 1573561434.209 - - '0.023651123046875' - - - 1573561494.209 - - '0.02362060546875' - - - 1573561554.209 - - '0.0236358642578125' - - - 1573561614.209 - - '0.02362060546875' - - - 1573561674.209 - - '0.02362060546875' - - - 1573561734.209 - - '0.02362060546875' - - - 1573561794.209 - - '0.02362060546875' - - - 1573561854.209 - - '0.02362060546875' - - - 1573561914.209 - - '0.023651123046875' - - - 1573561974.209 - - '0.02362060546875' - - - 1573562034.209 - - '0.02362060546875' - - - 1573562094.209 - - '0.02362060546875' - - - 1573562154.209 - - '0.02362060546875' - - - 1573562214.209 - - '0.023624420166015625' - - - 1573562274.209 - - '0.02362060546875' - - - 1573562334.209 - - '0.023868560791015625' - - - 1573562394.209 - - '0.02374267578125' - - - 1573562454.209 - - '0.02362060546875' - - - 1573562514.209 - - '0.02362060546875' - - - 1573562574.209 - - '0.02362060546875' - - - 1573562634.209 - - '0.02362060546875' - - - 1573562694.209 - - '0.023639678955078125' - - - 1573562754.209 - - '0.0236358642578125' - - - 1573562814.209 - - '0.02362060546875' - - - 1573562874.209 - - '0.0236358642578125' - - - 1573562934.209 - - '0.023651123046875' - - - 1573562994.209 - - '0.02362060546875' - - - 1573563054.209 - - '0.023624420166015625' - - - 1573563114.209 - - '0.02362060546875' - - - 1573563174.209 - - '0.02362060546875' - - - 1573563234.209 - - '0.02362060546875' - - - 1573563294.209 - - '0.02362060546875' - - - 1573563354.209 - - '0.02362060546875' - - - 1573563414.209 - - '0.023651123046875' - - - 1573563474.209 - - '0.023651123046875' - - - 1573563534.209 - - '0.023651123046875' - - - 1573563594.209 - - '0.023773193359375' - - - 1573563654.209 - - '0.023681640625' - - - 1573563714.209 - - '0.023895263671875' - - - 1573563774.209 - - '0.023651123046875' - - - 1573563834.209 - - '0.023651123046875' - - - 1573563894.209 - - '0.023651123046875' - - - 1573563954.209 - - '0.0236663818359375' - - - 1573564014.209 - - '0.023651123046875' - - - 1573564074.209 - - '0.023681640625' - - - 1573564134.209 - - '0.0236663818359375' - - - 1573564194.209 - - '0.0236663818359375' - - - 1573564254.209 - - '0.023651123046875' - - - 1573564314.209 - - '0.023651123046875' - - - 1573564374.209 - - '0.023651123046875' - - - 1573564434.209 - - '0.023773193359375' - - - 1573564494.209 - - '0.023651123046875' - - - 1573564554.209 - - '0.023681640625' - - - 1573564614.209 - - '0.023773193359375' - - - 1573564674.209 - - '0.023651123046875' - - - 1573564734.209 - - '0.023651123046875' - - - 1573564794.209 - - '0.023651123046875' - - - 1573564854.209 - - '0.023651123046875' - - - 1573564914.209 - - '0.023651123046875' - - - 1573564974.209 - - '0.023651123046875' - - - 1573565034.209 - - '0.023651123046875' - - - 1573565094.209 - - '0.023895263671875' \ No newline at end of file + - - 1576719533.248 + - '0.0006172414678571515' + - - 1576719593.248 + - '0.0006189408976190352' + - - 1576719653.248 + - '0.0006182154988094691' + - - 1576719713.248 + - '0.0006194998404763076' + - - 1576719773.248 + - '0.0006194687678569856' + - - 1576719833.248 + - '0.0006171203535713976' + - - 1576719893.248 + - '0.0006244061773808577' + - - 1576719953.248 + - '0.0006170288511561634' + - - 1576720013.248 + - '0.0006243750281248557' + - - 1576720073.248 + - '0.0006152456571427256' + - - 1576720133.248 + - '0.0006215679095237733' + - - 1576720193.248 + - '0.0006218523571429083' + - - 1576720253.248 + - '0.0006200312440475792' + - - 1576720313.248 + - '0.0006214166202382676' + - - 1576720373.248 + - '0.0006152486976191084' + - - 1576720433.248 + - '0.0006136406750000235' + - - 1576720493.248 + - '0.0006135999154761997' + - - 1576720553.248 + - '0.0006126559190475756' + - - 1576720613.248 + - '0.0006153160392857769' + - - 1576720673.248 + - '0.0006146447178572262' + - - 1576720733.248 + - '0.0006146970476189988' + - - 1576720793.248 + - '0.0006219259035715042' + - - 1576720853.248 + - '0.0006111198750001481' + - - 1576720913.248 + - '0.0006169941035715337' + - - 1576720973.248 + - '0.0006102626761905379' + - - 1576721033.248 + - '0.0006163839964285346' +180: +- metric: {} + values: + - - 1576719533.248 + - '0.0006172414678571515' + - - 1576719593.248 + - '0.0006189408976190352' + - - 1576719653.248 + - '0.0006182154988094691' + - - 1576719713.248 + - '0.0006194998404763076' + - - 1576719773.248 + - '0.0006194687678569856' + - - 1576719833.248 + - '0.0006171203535713976' + - - 1576719893.248 + - '0.0006244061773808577' + - - 1576719953.248 + - '0.0006170288511561634' + - - 1576720013.248 + - '0.0006243750281248557' + - - 1576720073.248 + - '0.0006152456571427256' + - - 1576720133.248 + - '0.0006215679095237733' + - - 1576720193.248 + - '0.0006218523571429083' + - - 1576720253.248 + - '0.0006200312440475792' + - - 1576720313.248 + - '0.0006214166202382676' + - - 1576720373.248 + - '0.0006152486976191084' + - - 1576720433.248 + - '0.0006136406750000235' + - - 1576720493.248 + - '0.0006135999154761997' + - - 1576720553.248 + - '0.0006126559190475756' + - - 1576720613.248 + - '0.0006153160392857769' + - - 1576720673.248 + - '0.0006146447178572262' + - - 1576720733.248 + - '0.0006146970476189988' + - - 1576720793.248 + - '0.0006219259035715042' + - - 1576720853.248 + - '0.0006111198750001481' + - - 1576720913.248 + - '0.0006169941035715337' + - - 1576720973.248 + - '0.0006102626761905379' + - - 1576721033.248 + - '0.0006163839964285346' +480: +- metric: {} + values: + - - 1576719533.248 + - '0.0006172414678571515' + - - 1576719593.248 + - '0.0006189408976190352' + - - 1576719653.248 + - '0.0006182154988094691' + - - 1576719713.248 + - '0.0006194998404763076' + - - 1576719773.248 + - '0.0006194687678569856' + - - 1576719833.248 + - '0.0006171203535713976' + - - 1576719893.248 + - '0.0006244061773808577' + - - 1576719953.248 + - '0.0006170288511561634' + - - 1576720013.248 + - '0.0006243750281248557' + - - 1576720073.248 + - '0.0006152456571427256' + - - 1576720133.248 + - '0.0006215679095237733' + - - 1576720193.248 + - '0.0006218523571429083' + - - 1576720253.248 + - '0.0006200312440475792' + - - 1576720313.248 + - '0.0006214166202382676' + - - 1576720373.248 + - '0.0006152486976191084' + - - 1576720433.248 + - '0.0006136406750000235' + - - 1576720493.248 + - '0.0006135999154761997' + - - 1576720553.248 + - '0.0006126559190475756' + - - 1576720613.248 + - '0.0006153160392857769' + - - 1576720673.248 + - '0.0006146447178572262' + - - 1576720733.248 + - '0.0006146970476189988' + - - 1576720793.248 + - '0.0006219259035715042' + - - 1576720853.248 + - '0.0006111198750001481' + - - 1576720913.248 + - '0.0006169941035715337' + - - 1576720973.248 + - '0.0006102626761905379' + - - 1576721033.248 + - '0.0006163839964285346' +1440: +- metric: {} + values: + - - 1576719533.248 + - '0.0006172414678571515' + - - 1576719593.248 + - '0.0006189408976190352' + - - 1576719653.248 + - '0.0006182154988094691' + - - 1576719713.248 + - '0.0006194998404763076' + - - 1576719773.248 + - '0.0006194687678569856' + - - 1576719833.248 + - '0.0006171203535713976' + - - 1576719893.248 + - '0.0006244061773808577' + - - 1576719953.248 + - '0.0006170288511561634' + - - 1576720013.248 + - '0.0006243750281248557' + - - 1576720073.248 + - '0.0006152456571427256' + - - 1576720133.248 + - '0.0006215679095237733' + - - 1576720193.248 + - '0.0006218523571429083' + - - 1576720253.248 + - '0.0006200312440475792' + - - 1576720313.248 + - '0.0006214166202382676' + - - 1576720373.248 + - '0.0006152486976191084' + - - 1576720433.248 + - '0.0006136406750000235' + - - 1576720493.248 + - '0.0006135999154761997' + - - 1576720553.248 + - '0.0006126559190475756' + - - 1576720613.248 + - '0.0006153160392857769' + - - 1576720673.248 + - '0.0006146447178572262' + - - 1576720733.248 + - '0.0006146970476189988' + - - 1576720793.248 + - '0.0006219259035715042' + - - 1576720853.248 + - '0.0006111198750001481' + - - 1576720913.248 + - '0.0006169941035715337' + - - 1576720973.248 + - '0.0006102626761905379' + - - 1576721033.248 + - '0.0006163839964285346' +4320: +- metric: {} + values: + - - 1576719533.248 + - '0.0006172414678571515' + - - 1576719593.248 + - '0.0006189408976190352' + - - 1576719653.248 + - '0.0006182154988094691' + - - 1576719713.248 + - '0.0006194998404763076' + - - 1576719773.248 + - '0.0006194687678569856' + - - 1576719833.248 + - '0.0006171203535713976' + - - 1576719893.248 + - '0.0006244061773808577' + - - 1576719953.248 + - '0.0006170288511561634' + - - 1576720013.248 + - '0.0006243750281248557' + - - 1576720073.248 + - '0.0006152456571427256' + - - 1576720133.248 + - '0.0006215679095237733' + - - 1576720193.248 + - '0.0006218523571429083' + - - 1576720253.248 + - '0.0006200312440475792' + - - 1576720313.248 + - '0.0006214166202382676' + - - 1576720373.248 + - '0.0006152486976191084' + - - 1576720433.248 + - '0.0006136406750000235' + - - 1576720493.248 + - '0.0006135999154761997' + - - 1576720553.248 + - '0.0006126559190475756' + - - 1576720613.248 + - '0.0006153160392857769' + - - 1576720673.248 + - '0.0006146447178572262' + - - 1576720733.248 + - '0.0006146970476189988' + - - 1576720793.248 + - '0.0006219259035715042' + - - 1576720853.248 + - '0.0006111198750001481' + - - 1576720913.248 + - '0.0006169941035715337' + - - 1576720973.248 + - '0.0006102626761905379' + - - 1576721033.248 + - '0.0006163839964285346' +10080: +- metric: {} + values: + - - 1576719533.248 + - '0.0006172414678571515' + - - 1576719593.248 + - '0.0006189408976190352' + - - 1576719653.248 + - '0.0006182154988094691' + - - 1576719713.248 + - '0.0006194998404763076' + - - 1576719773.248 + - '0.0006194687678569856' + - - 1576719833.248 + - '0.0006171203535713976' + - - 1576719893.248 + - '0.0006244061773808577' + - - 1576719953.248 + - '0.0006170288511561634' + - - 1576720013.248 + - '0.0006243750281248557' + - - 1576720073.248 + - '0.0006152456571427256' + - - 1576720133.248 + - '0.0006215679095237733' + - - 1576720193.248 + - '0.0006218523571429083' + - - 1576720253.248 + - '0.0006200312440475792' + - - 1576720313.248 + - '0.0006214166202382676' + - - 1576720373.248 + - '0.0006152486976191084' + - - 1576720433.248 + - '0.0006136406750000235' + - - 1576720493.248 + - '0.0006135999154761997' + - - 1576720553.248 + - '0.0006126559190475756' + - - 1576720613.248 + - '0.0006153160392857769' + - - 1576720673.248 + - '0.0006146447178572262' + - - 1576720733.248 + - '0.0006146970476189988' + - - 1576720793.248 + - '0.0006219259035715042' + - - 1576720853.248 + - '0.0006111198750001481' + - - 1576720913.248 + - '0.0006169941035715337' + - - 1576720973.248 + - '0.0006102626761905379' + - - 1576721033.248 + - '0.0006163839964285346' + \ No newline at end of file diff --git a/spec/fixtures/lib/gitlab/import_export/complex/project.json b/spec/fixtures/lib/gitlab/import_export/complex/project.json index 583d6c7b78a9280276ae495cf72a78f26a94fdd0..7d784fbd54f474cd63a7c3443b62ad8eb33a6907 100644 --- a/spec/fixtures/lib/gitlab/import_export/complex/project.json +++ b/spec/fixtures/lib/gitlab/import_export/complex/project.json @@ -80,6 +80,17 @@ "issue_id": 40 } ], + "award_emoji": [ + { + "id": 1, + "name": "musical_keyboard", + "user_id": 1, + "awardable_type": "Issue", + "awardable_id": 40, + "created_at": "2020-01-07T11:55:22.234Z", + "updated_at": "2020-01-07T11:55:22.234Z" + } + ], "zoom_meetings": [ { "id": 1, @@ -188,7 +199,18 @@ "author": { "name": "User 4" }, - "events": [] + "events": [], + "award_emoji": [ + { + "id": 1, + "name": "clapper", + "user_id": 1, + "awardable_type": "Note", + "awardable_id": 351, + "created_at": "2020-01-07T11:55:22.234Z", + "updated_at": "2020-01-07T11:55:22.234Z" + } + ] }, { "id": 352, @@ -1980,7 +2002,7 @@ }, { "id": 31, - "title": "Libero nam magnam incidunt eaque placeat error et.", + "title": "issue_with_timelogs", "author_id": 16, "project_id": 5, "created_at": "2016-06-14T15:02:07.280Z", @@ -1994,6 +2016,16 @@ "confidential": false, "due_date": null, "moved_to_id": null, + "timelogs": [ + { + "id": 1, + "time_spent": 72000, + "user_id": 1, + "created_at": "2019-12-27T09:15:22.302Z", + "updated_at": "2019-12-27T09:15:22.302Z", + "spent_at": "2019-12-27T00:00:00.000Z" + } + ], "notes": [ { "id": 423, @@ -2297,10 +2329,58 @@ "updated_at": "2019-11-05T15:37:24.645Z" } ], - "notes": [] + "notes": [ + { + "id": 872, + "note": "This is a test note", + "noteable_type": "Snippet", + "author_id": 1, + "created_at": "2019-11-05T15:37:24.645Z", + "updated_at": "2019-11-05T15:37:24.645Z", + "noteable_id": 1, + "author": { + "name": "Random name" + }, + "events": [], + "award_emoji": [ + { + "id": 12, + "name": "thumbsup", + "user_id": 1, + "awardable_type": "Note", + "awardable_id": 872, + "created_at": "2019-11-05T15:37:21.287Z", + "updated_at": "2019-11-05T15:37:21.287Z" + } + ] + } + ] + } + ], + "releases": [ + { + "id": 1, + "tag": "release-1.1", + "description": "Some release notes", + "project_id": 5, + "created_at": "2019-12-26T10:17:14.621Z", + "updated_at": "2019-12-26T10:17:14.621Z", + "author_id": 1, + "name": "release-1.1", + "sha": "901de3a8bd5573f4a049b1457d28bc1592ba6bf9", + "released_at": "2019-12-26T10:17:14.615Z", + "links": [ + { + "id": 1, + "release_id" : 1, + "url": "http://localhost/namespace6/project6/-/jobs/140463678/artifacts/download", + "name": "release-1.1.dmg", + "created_at": "2019-12-26T10:17:14.621Z", + "updated_at": "2019-12-26T10:17:14.621Z" + } + ] } ], - "releases": [], "project_members": [ { "id": 36, @@ -2434,7 +2514,18 @@ "author": { "name": "User 4" }, - "events": [] + "events": [], + "award_emoji": [ + { + "id": 1, + "name": "tada", + "user_id": 1, + "awardable_type": "Note", + "awardable_id": 1, + "created_at": "2019-11-05T15:37:21.287Z", + "updated_at": "2019-11-05T15:37:21.287Z" + } + ] }, { "id": 672, @@ -2840,7 +2931,27 @@ "author_id": 1 } ], - "approvals_before_merge": 1 + "approvals_before_merge": 1, + "award_emoji": [ + { + "id": 1, + "name": "thumbsup", + "user_id": 1, + "awardable_type": "MergeRequest", + "awardable_id": 27, + "created_at": "2020-01-07T11:21:21.235Z", + "updated_at": "2020-01-07T11:21:21.235Z" + }, + { + "id": 2, + "name": "drum", + "user_id": 1, + "awardable_type": "MergeRequest", + "awardable_id": 27, + "created_at": "2020-01-07T11:21:21.235Z", + "updated_at": "2020-01-07T11:21:21.235Z" + } + ] }, { "id": 26, @@ -6738,6 +6849,40 @@ "duration": null, "stages": [ ] + }, + { + "id": 42, + "project_id": 5, + "ref": "master", + "sha": "ce84140e8b878ce6e7c4d298c7202ff38170e3ac", + "before_sha": null, + "push_data": null, + "created_at": "2016-03-22T15:20:35.763Z", + "updated_at": "2016-03-22T15:20:35.763Z", + "tag": false, + "yaml_errors": null, + "committed_at": null, + "status": "failed", + "started_at": null, + "finished_at": null, + "duration": null, + "stages": [ + ], + "source": "external_pull_request_event", + "external_pull_request": + { + "id": 3, + "pull_request_iid": 4, + "source_branch": "feature", + "target_branch": "master", + "source_repository": "the-repository", + "target_repository": "the-repository", + "source_sha": "ce84140e8b878ce6e7c4d298c7202ff38170e3ac", + "target_sha": "a09386439ca39abe575675ffd4b89ae824fec22f", + "status": "open", + "created_at": "2016-03-22T15:20:35.763Z", + "updated_at": "2016-03-22T15:20:35.763Z" + } } ], "triggers": [ @@ -6757,6 +6902,21 @@ "updated_at": "2017-01-16T15:25:29.637Z" } ], + "pipeline_schedules": [ + { + "id": 1, + "description": "Schedule Description", + "ref": "master", + "cron": "0 4 * * 0", + "cron_timezone": "UTC", + "next_run_at": "2019-12-29T04:19:00.000Z", + "project_id": 5, + "owner_id": 1, + "active": true, + "created_at": "2019-12-26T10:14:57.778Z", + "updated_at": "2019-12-26T10:14:57.778Z" + } + ], "container_expiration_policy": { "created_at": "2019-12-13 13:45:04 UTC", "updated_at": "2019-12-13 13:45:04 UTC", @@ -7276,6 +7436,33 @@ "ci_cd_settings": { "group_runners_enabled": false }, + "auto_devops": { + "id": 1, + "created_at": "2017-10-19T15:36:23.466Z", + "updated_at": "2017-10-19T15:36:23.466Z", + "enabled": null, + "deploy_strategy": "continuous" + }, + "error_tracking_setting": { + "api_url": "https://gitlab.example.com/api/0/projects/sentry-org/sentry-project", + "project_name": "Sentry Project", + "organization_name": "Sentry Org" + }, + "external_pull_requests": [ + { + "id": 3, + "pull_request_iid": 4, + "source_branch": "feature", + "target_branch": "master", + "source_repository": "the-repository", + "target_repository": "the-repository", + "source_sha": "ce84140e8b878ce6e7c4d298c7202ff38170e3ac", + "target_sha": "a09386439ca39abe575675ffd4b89ae824fec22f", + "status": "open", + "created_at": "2019-12-24T14:04:50.053Z", + "updated_at": "2019-12-24T14:05:18.138Z" + } + ], "boards": [ { "id": 29, diff --git a/spec/fixtures/lib/gitlab/import_export/group/project.json b/spec/fixtures/lib/gitlab/import_export/group/project.json index 47faf271cca90613647900ca0a525de308837113..ce4fa1981ff4496956de0a02e6a502ba3be94ea0 100644 --- a/spec/fixtures/lib/gitlab/import_export/group/project.json +++ b/spec/fixtures/lib/gitlab/import_export/group/project.json @@ -175,6 +175,67 @@ } } ] + }, + { + "id": 3, + "title": "Issue with Epic", + "author_id": 1, + "project_id": 8, + "created_at": "2019-12-08T19:41:11.233Z", + "updated_at": "2019-12-08T19:41:53.194Z", + "position": 0, + "branch_name": null, + "description": "Donec at nulla vitae sem molestie rutrum ut at sem.", + "state": "opened", + "iid": 3, + "updated_by_id": null, + "confidential": false, + "due_date": null, + "moved_to_id": null, + "issue_assignees": [], + "notes": [], + "milestone": { + "id": 2, + "title": "A group milestone", + "description": "Group-level milestone", + "due_date": null, + "created_at": "2016-06-14T15:02:04.415Z", + "updated_at": "2016-06-14T15:02:04.415Z", + "state": "active", + "iid": 1, + "group_id": 100 + }, + "epic": { + "id": 1, + "group_id": 5, + "author_id": 1, + "assignee_id": null, + "iid": 1, + "updated_by_id": null, + "last_edited_by_id": null, + "lock_version": 0, + "start_date": null, + "end_date": null, + "last_edited_at": null, + "created_at": "2019-12-08T19:37:07.098Z", + "updated_at": "2019-12-08T19:43:11.568Z", + "title": "An epic", + "description": null, + "start_date_sourcing_milestone_id": null, + "due_date_sourcing_milestone_id": null, + "start_date_fixed": null, + "due_date_fixed": null, + "start_date_is_fixed": null, + "due_date_is_fixed": null, + "closed_by_id": null, + "closed_at": null, + "parent_id": null, + "relative_position": null, + "state_id": "opened", + "start_date_sourcing_epic_id": null, + "due_date_sourcing_epic_id": null, + "milestone_id": null + } } ], "snippets": [ diff --git a/spec/fixtures/markdown.md.erb b/spec/fixtures/markdown.md.erb index b19b45928d93d1697f15ab2f56ca0d7e0c89571d..59795c835a276b2c05034f3aba6b8966c3dc4f37 100644 --- a/spec/fixtures/markdown.md.erb +++ b/spec/fixtures/markdown.md.erb @@ -111,7 +111,13 @@ Markdown should be usable inside a link. Let's try! - [**text**](#link-strong) - [`text`](#link-code) -### RelativeLinkFilter +### UploadLinkFilter + +Linking to an upload in this project should work: +[Relative Upload Link](/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg) + + +### RepositoryLinkFilter Linking to a file relative to this project's repository should work. diff --git a/spec/fixtures/not_a_png.png b/spec/fixtures/not_a_png.png new file mode 100644 index 0000000000000000000000000000000000000000..932f9efaed97c5d7ffa468c7b05fc74eb9f57dd2 Binary files /dev/null and b/spec/fixtures/not_a_png.png differ diff --git a/spec/fixtures/referees/metrics_referee.json.gz b/spec/fixtures/referees/metrics_referee.json.gz new file mode 100644 index 0000000000000000000000000000000000000000..88b7de6fa61e74a61375f2deb63a8f6efb5dc064 Binary files /dev/null and b/spec/fixtures/referees/metrics_referee.json.gz differ diff --git a/spec/fixtures/referees/network_referee.json.gz b/spec/fixtures/referees/network_referee.json.gz new file mode 100644 index 0000000000000000000000000000000000000000..88b7de6fa61e74a61375f2deb63a8f6efb5dc064 Binary files /dev/null and b/spec/fixtures/referees/network_referee.json.gz differ diff --git a/spec/fixtures/sentry/issue_latest_event_no_stack_sample_response.json b/spec/fixtures/sentry/issue_latest_event_no_stack_sample_response.json new file mode 100644 index 0000000000000000000000000000000000000000..c0860ebbbd807e8d0e19b06a2ae2bd8c6124e070 --- /dev/null +++ b/spec/fixtures/sentry/issue_latest_event_no_stack_sample_response.json @@ -0,0 +1,300 @@ +{ + "eventID": "333b98e3b91341d8a6247edff171d8cf", + "dist": null, + "userReport": null, + "projectID": "1788822", + "previousEventID": "d32f1ce60de14911beec5109d9b5bdbd", + "message": null, + "id": "333b98e3b91341d8a6247edff171d8cf", + "size": 77202, + "errors": [ + { + "data": { + "reason": "the cookie is missing a name/value pair", + "name": "request.cookies", + "value": "********" + }, + "message": "Discarded invalid value", + "type": "invalid_data" + }, + { + "data": { + "reason": "the cookie is missing a name/value pair", + "name": "request.cookies", + "value": "********" + }, + "message": "Discarded invalid value", + "type": "invalid_data" + } + ], + "culprit": "/", + "title": "ActiveRecord::NoDatabaseError: FATAL: database \"test_development\" does not exist", + "sdkUpdates": [], + "platform": "ruby", + "location": "active_record/connection_adapters/postgresql_adapter.rb", + "nextEventID": null, + "type": "error", + "metadata": { + "function": "rescue in connect", + "type": "ActiveRecord::NoDatabaseError", + "value": "FATAL: database \"test_development\" does not exist\n", + "filename": "active_record/connection_adapters/postgresql_adapter.rb" + }, + "groupingConfig": { + "enhancements": "eJybzDhxY3J-bm5-npWRgaGlroGxrpHxBABcTQcY", + "id": "newstyle:2019-05-08" + }, + "crashFile": null, + "tags": [ + { + "value": "Chrome 78.0.3904", + "key": "browser", + "_meta": null + }, + { + "value": "Chrome", + "key": "browser.name", + "_meta": null + }, + { + "value": "Mac OS X 10.15.1", + "key": "client_os", + "_meta": null + }, + { + "value": "Mac OS X", + "key": "client_os.name", + "_meta": null + }, + { + "value": "development", + "key": "environment", + "_meta": null + }, + { + "value": "error", + "key": "level", + "_meta": null + }, + { + "value": "ruby", + "key": "logger", + "_meta": null + }, + { + "value": "b56ae26", + "key": "release", + "_meta": null + }, + { + "value": "Seans-MBP.fritz.box", + "key": "server_name", + "_meta": null + }, + { + "value": "/", + "key": "transaction", + "_meta": null + }, + { + "value": "http://localhost:3001/", + "key": "url", + "_meta": null + }, + { + "query": "user.ip:\"::1\"", + "value": "ip:::1", + "key": "user", + "_meta": null + } + ], + "dateCreated": "2019-12-08T21:48:07Z", + "dateReceived": "2019-12-08T21:48:08.579417Z", + "user": { + "username": null, + "name": null, + "ip_address": "::1", + "email": null, + "data": null, + "id": null + }, + "entries": [], + "packages": { + "coffee-script": "2.4.1", + "uglifier": "4.1.20", + "ffi": "1.11.1", + "actioncable": "5.2.3", + "io-like": "0.3.0", + "rb-inotify": "0.10.0", + "spring": "2.1.0", + "loofah": "2.2.3", + "selenium-webdriver": "3.142.3", + "marcel": "0.3.3", + "sass-listen": "4.0.0", + "nokogiri": "1.10.4", + "activestorage": "5.2.3", + "activejob": "5.2.3", + "mimemagic": "0.3.3", + "faraday": "0.17.1", + "execjs": "2.7.0", + "activesupport": "5.2.3", + "rails-html-sanitizer": "1.2.0", + "byebug": "11.0.1", + "xpath": "3.2.0", + "msgpack": "1.3.1", + "childprocess": "1.0.1", + "rails-dom-testing": "2.0.3", + "public_suffix": "3.1.1", + "mini_mime": "1.0.2", + "arel": "9.0.0", + "coffee-rails": "4.2.2", + "bundler": "1.17.3", + "rails": "5.2.3", + "globalid": "0.4.2", + "sentry-raven": "2.12.3", + "concurrent-ruby": "1.1.5", + "capybara": "3.28.0", + "regexp_parser": "1.6.0", + "sprockets-rails": "3.2.1", + "tzinfo": "1.2.5", + "mail": "2.7.1", + "actionview": "5.2.3", + "rubyzip": "1.2.3", + "coffee-script-source": "1.12.2", + "listen": "3.1.5", + "i18n": "1.6.0", + "erubi": "1.8.0", + "rake": "12.3.3", + "nio4r": "2.4.0", + "activemodel": "5.2.3", + "web-console": "3.7.0", + "ruby_dep": "1.5.0", + "turbolinks": "5.2.0", + "archive-zip": "0.12.0", + "method_source": "0.9.2", + "minitest": "5.11.3", + "puma": "3.12.1", + "sass-rails": "5.1.0", + "chromedriver-helper": "2.1.1", + "sprockets": "3.7.2", + "bindex": "0.8.1", + "actionmailer": "5.2.3", + "rack-test": "1.1.0", + "bootsnap": "1.4.4", + "railties": "5.2.3", + "mini_portile2": "2.4.0", + "crass": "1.0.4", + "websocket-extensions": "0.1.4", + "multipart-post": "2.1.1", + "rb-fsevent": "0.10.3", + "jbuilder": "2.9.1", + "pg": "1.1.4", + "sass": "3.7.4", + "activerecord": "5.2.3", + "builder": "3.2.3", + "spring-watcher-listen": "2.0.1", + "websocket-driver": "0.7.1", + "thor": "0.20.3", + "thread_safe": "0.3.6", + "addressable": "2.6.0", + "prometheus-client-mmap": "0.9.8", + "tilt": "2.0.9", + "actionpack": "5.2.3", + "rack": "2.0.7", + "turbolinks-source": "5.2.0" + }, + "sdk": { + "version": "2.12.3", + "name": "raven-ruby" + }, + "_meta": { + "user": null, + "context": null, + "entries": { + "1": { + "data": { + "": null, + "cookies": { + "": { + "err": [ + [ + "invalid_data", + { + "reason": "the cookie is missing a name/value pair" + } + ] + ], + "val": "********" + } + }, + "url": null, + "headers": null, + "env": null, + "query": null, + "data": null, + "method": null + } + } + }, + "contexts": null, + "message": null, + "packages": null, + "tags": {}, + "sdk": null + }, + "contexts": { + "browser": { + "version": "78.0.3904", + "type": "browser", + "name": "Chrome" + }, + "client_os": { + "version": "10.15.1", + "type": "os", + "name": "Mac OS X" + } + }, + "fingerprints": [ + "6aa133ea51857634f2d113de52b5cc61", + "e1613eeb169241eab95b76ab52a80c68" + ], + "context": { + "server": { + "runtime": { + "version": "ruby 2.6.5p114 (2019-10-01 revision 67812) [x86_64-darwin18]", + "name": "ruby" + }, + "os": { + "kernel_version": "Darwin Seans-MBP.fritz.box 19.0.0 Darwin Kernel Version 19.0.0: Thu Oct 17 16:17:15 PDT 2019; root:xnu-6153.41.3~29/RELEASE_X86_64 x86_64", + "version": "Darwin Kernel Version 19.0.0: Thu Oct 17 16:17:15 PDT 2019; root:xnu-6153.41.3~29/RELEASE_X86_64", + "build": "19.0.0", + "name": "Darwin" + } + } + }, + "release": { + "dateReleased": null, + "commitCount": 0, + "url": null, + "data": {}, + "lastDeploy": null, + "deployCount": 0, + "dateCreated": "2019-12-08T21:47:47Z", + "lastEvent": "2019-12-09T21:52:05Z", + "version": "b56ae26", + "firstEvent": "2019-12-08T21:47:47Z", + "lastCommit": null, + "shortVersion": "b56ae26", + "authors": [], + "owner": null, + "newGroups": 26, + "ref": null, + "projects": [ + { + "slug": "gitlab-03", + "name": "gitlab-03" + } + ] + }, + "groupID": "1378364652" +} diff --git a/spec/fixtures/sentry/issue_latest_event_sample_response.json b/spec/fixtures/sentry/issue_latest_event_sample_response.json new file mode 100644 index 0000000000000000000000000000000000000000..f047eb07e1f3d934f9b265c9204acce786f52170 --- /dev/null +++ b/spec/fixtures/sentry/issue_latest_event_sample_response.json @@ -0,0 +1,5299 @@ +{ + "eventID": "333b98e3b91341d8a6247edff171d8cf", + "dist": null, + "userReport": null, + "projectID": "1788822", + "previousEventID": "d32f1ce60de14911beec5109d9b5bdbd", + "message": null, + "id": "333b98e3b91341d8a6247edff171d8cf", + "size": 77202, + "errors": [ + { + "data": { + "reason": "the cookie is missing a name/value pair", + "name": "request.cookies", + "value": "********" + }, + "message": "Discarded invalid value", + "type": "invalid_data" + }, + { + "data": { + "reason": "the cookie is missing a name/value pair", + "name": "request.cookies", + "value": "********" + }, + "message": "Discarded invalid value", + "type": "invalid_data" + } + ], + "culprit": "/", + "title": "ActiveRecord::NoDatabaseError: FATAL: database \"test_development\" does not exist", + "sdkUpdates": [], + "platform": "ruby", + "location": "active_record/connection_adapters/postgresql_adapter.rb", + "nextEventID": null, + "type": "error", + "metadata": { + "function": "rescue in connect", + "type": "ActiveRecord::NoDatabaseError", + "value": "FATAL: database \"test_development\" does not exist\n", + "filename": "active_record/connection_adapters/postgresql_adapter.rb" + }, + "groupingConfig": { + "enhancements": "eJybzDhxY3J-bm5-npWRgaGlroGxrpHxBABcTQcY", + "id": "newstyle:2019-05-08" + }, + "crashFile": null, + "tags": [ + { + "value": "Chrome 78.0.3904", + "key": "browser", + "_meta": null + }, + { + "value": "Chrome", + "key": "browser.name", + "_meta": null + }, + { + "value": "Mac OS X 10.15.1", + "key": "client_os", + "_meta": null + }, + { + "value": "Mac OS X", + "key": "client_os.name", + "_meta": null + }, + { + "value": "development", + "key": "environment", + "_meta": null + }, + { + "value": "error", + "key": "level", + "_meta": null + }, + { + "value": "ruby", + "key": "logger", + "_meta": null + }, + { + "value": "b56ae26", + "key": "release", + "_meta": null + }, + { + "value": "Seans-MBP.fritz.box", + "key": "server_name", + "_meta": null + }, + { + "value": "/", + "key": "transaction", + "_meta": null + }, + { + "value": "http://localhost:3001/", + "key": "url", + "_meta": null + }, + { + "query": "user.ip:\"::1\"", + "value": "ip:::1", + "key": "user", + "_meta": null + } + ], + "dateCreated": "2019-12-08T21:48:07Z", + "dateReceived": "2019-12-08T21:48:08.579417Z", + "user": { + "username": null, + "name": null, + "ip_address": "::1", + "email": null, + "data": null, + "id": null + }, + "entries": [ + { + "type": "exception", + "data": { + "values": [ + { + "stacktrace": { + "frames": [ + { + "function": "block in spawn_thread", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/puma-3.12.1/lib/puma/thread_pool.rb", + "inApp": false, + "lineNo": 135, + "module": null, + "filename": "puma/thread_pool.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 132, + " end\n" + ], + [ + 133, + "\n" + ], + [ + 134, + " begin\n" + ], + [ + 135, + " block.call(work, *extra)\n" + ], + [ + 136, + " rescue Exception => e\n" + ], + [ + 137, + " STDERR.puts \"Error reached top of thread-pool: #{e.message} (#{e.class})\"\n" + ], + [ + 138, + " end\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "block in run", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/puma-3.12.1/lib/puma/server.rb", + "inApp": false, + "lineNo": 334, + "module": null, + "filename": "puma/server.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 331, + " client.close\n" + ], + [ + 332, + " else\n" + ], + [ + 333, + " if process_now\n" + ], + [ + 334, + " process_client client, buffer\n" + ], + [ + 335, + " else\n" + ], + [ + 336, + " client.set_timeout @first_data_timeout\n" + ], + [ + 337, + " @reactor.add client\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "process_client", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/puma-3.12.1/lib/puma/server.rb", + "inApp": false, + "lineNo": 474, + "module": null, + "filename": "puma/server.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 471, + " close_socket = true\n" + ], + [ + 472, + "\n" + ], + [ + 473, + " while true\n" + ], + [ + 474, + " case handle_request(client, buffer)\n" + ], + [ + 475, + " when false\n" + ], + [ + 476, + " return\n" + ], + [ + 477, + " when :async\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "handle_request", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/puma-3.12.1/lib/puma/server.rb", + "inApp": false, + "lineNo": 660, + "module": null, + "filename": "puma/server.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 657, + "\n" + ], + [ + 658, + " begin\n" + ], + [ + 659, + " begin\n" + ], + [ + 660, + " status, headers, res_body = @app.call(env)\n" + ], + [ + 661, + "\n" + ], + [ + 662, + " return :async if req.hijacked\n" + ], + [ + 663, + "\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "call", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/puma-3.12.1/lib/puma/configuration.rb", + "inApp": false, + "lineNo": 227, + "module": null, + "filename": "puma/configuration.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 224, + "\n" + ], + [ + 225, + " def call(env)\n" + ], + [ + 226, + " env[Const::PUMA_CONFIG] = @config\n" + ], + [ + 227, + " @app.call(env)\n" + ], + [ + 228, + " end\n" + ], + [ + 229, + " end\n" + ], + [ + 230, + "\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "call", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/prometheus-client-mmap-0.9.8/lib/prometheus/client/rack/collector.rb", + "inApp": false, + "lineNo": 24, + "module": null, + "filename": "prometheus/client/rack/collector.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 21, + " end\n" + ], + [ + 22, + "\n" + ], + [ + 23, + " def call(env) # :nodoc:\n" + ], + [ + 24, + " trace(env) { @app.call(env) }\n" + ], + [ + 25, + " end\n" + ], + [ + 26, + "\n" + ], + [ + 27, + " protected\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "trace", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/prometheus-client-mmap-0.9.8/lib/prometheus/client/rack/collector.rb", + "inApp": false, + "lineNo": 61, + "module": null, + "filename": "prometheus/client/rack/collector.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 58, + "\n" + ], + [ + 59, + " def trace(env)\n" + ], + [ + 60, + " start = Time.now\n" + ], + [ + 61, + " yield.tap do |response|\n" + ], + [ + 62, + " duration = (Time.now - start).to_f\n" + ], + [ + 63, + " record(labels(env, response), duration)\n" + ], + [ + 64, + " end\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "block in call", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/prometheus-client-mmap-0.9.8/lib/prometheus/client/rack/collector.rb", + "inApp": false, + "lineNo": 24, + "module": null, + "filename": "prometheus/client/rack/collector.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 21, + " end\n" + ], + [ + 22, + "\n" + ], + [ + 23, + " def call(env) # :nodoc:\n" + ], + [ + 24, + " trace(env) { @app.call(env) }\n" + ], + [ + 25, + " end\n" + ], + [ + 26, + "\n" + ], + [ + 27, + " protected\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "call", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/prometheus-client-mmap-0.9.8/lib/prometheus/client/rack/exporter.rb", + "inApp": false, + "lineNo": 29, + "module": null, + "filename": "prometheus/client/rack/exporter.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 26, + " format = negotiate(env['HTTP_ACCEPT'], @acceptable)\n" + ], + [ + 27, + " format ? respond_with(format) : not_acceptable(FORMATS)\n" + ], + [ + 28, + " else\n" + ], + [ + 29, + " @app.call(env)\n" + ], + [ + 30, + " end\n" + ], + [ + 31, + " end\n" + ], + [ + 32, + "\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "call", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/railties-5.2.3/lib/rails/engine.rb", + "inApp": false, + "lineNo": 524, + "module": null, + "filename": "rails/engine.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 521, + " # Define the Rack API for this engine.\n" + ], + [ + 522, + " def call(env)\n" + ], + [ + 523, + " req = build_request env\n" + ], + [ + 524, + " app.call req.env\n" + ], + [ + 525, + " end\n" + ], + [ + 526, + "\n" + ], + [ + 527, + " # Defines additional Rack env configuration that is added on each call.\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "call", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/sentry-raven-2.12.3/lib/raven/integrations/rack.rb", + "inApp": false, + "lineNo": 51, + "module": null, + "filename": "raven/integrations/rack.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 48, + " Raven.context.transaction.push(env[\"PATH_INFO\"]) if env[\"PATH_INFO\"]\n" + ], + [ + 49, + "\n" + ], + [ + 50, + " begin\n" + ], + [ + 51, + " response = @app.call(env)\n" + ], + [ + 52, + " rescue Error\n" + ], + [ + 53, + " raise # Don't capture Raven errors\n" + ], + [ + 54, + " rescue Exception => e\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "call", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/rack-2.0.7/lib/rack/sendfile.rb", + "inApp": false, + "lineNo": 111, + "module": null, + "filename": "rack/sendfile.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 108, + " end\n" + ], + [ + 109, + "\n" + ], + [ + 110, + " def call(env)\n" + ], + [ + 111, + " status, headers, body = @app.call(env)\n" + ], + [ + 112, + " if body.respond_to?(:to_path)\n" + ], + [ + 113, + " case type = variation(env)\n" + ], + [ + 114, + " when 'X-Accel-Redirect'\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "call", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/actionpack-5.2.3/lib/action_dispatch/middleware/static.rb", + "inApp": false, + "lineNo": 127, + "module": null, + "filename": "action_dispatch/middleware/static.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 124, + " end\n" + ], + [ + 125, + " end\n" + ], + [ + 126, + "\n" + ], + [ + 127, + " @app.call(req.env)\n" + ], + [ + 128, + " end\n" + ], + [ + 129, + " end\n" + ], + [ + 130, + "end\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "call", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/actionpack-5.2.3/lib/action_dispatch/middleware/executor.rb", + "inApp": false, + "lineNo": 14, + "module": null, + "filename": "action_dispatch/middleware/executor.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 11, + " def call(env)\n" + ], + [ + 12, + " state = @executor.run!\n" + ], + [ + 13, + " begin\n" + ], + [ + 14, + " response = @app.call(env)\n" + ], + [ + 15, + " returned = response << ::Rack::BodyProxy.new(response.pop) { state.complete! }\n" + ], + [ + 16, + " ensure\n" + ], + [ + 17, + " state.complete! unless returned\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "call", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/activesupport-5.2.3/lib/active_support/cache/strategy/local_cache_middleware.rb", + "inApp": false, + "lineNo": 29, + "module": null, + "filename": "active_support/cache/strategy/local_cache_middleware.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 26, + "\n" + ], + [ + 27, + " def call(env)\n" + ], + [ + 28, + " LocalCacheRegistry.set_cache_for(local_cache_key, LocalStore.new)\n" + ], + [ + 29, + " response = @app.call(env)\n" + ], + [ + 30, + " response[2] = ::Rack::BodyProxy.new(response[2]) do\n" + ], + [ + 31, + " LocalCacheRegistry.set_cache_for(local_cache_key, nil)\n" + ], + [ + 32, + " end\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "call", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/rack-2.0.7/lib/rack/runtime.rb", + "inApp": false, + "lineNo": 22, + "module": null, + "filename": "rack/runtime.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 19, + "\n" + ], + [ + 20, + " def call(env)\n" + ], + [ + 21, + " start_time = Utils.clock_time\n" + ], + [ + 22, + " status, headers, body = @app.call(env)\n" + ], + [ + 23, + " request_time = Utils.clock_time - start_time\n" + ], + [ + 24, + "\n" + ], + [ + 25, + " unless headers.has_key?(@header_name)\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "call", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/rack-2.0.7/lib/rack/method_override.rb", + "inApp": false, + "lineNo": 22, + "module": null, + "filename": "rack/method_override.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 19, + " end\n" + ], + [ + 20, + " end\n" + ], + [ + 21, + "\n" + ], + [ + 22, + " @app.call(env)\n" + ], + [ + 23, + " end\n" + ], + [ + 24, + "\n" + ], + [ + 25, + " def method_override(env)\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "call", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/actionpack-5.2.3/lib/action_dispatch/middleware/request_id.rb", + "inApp": false, + "lineNo": 27, + "module": null, + "filename": "action_dispatch/middleware/request_id.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 24, + " def call(env)\n" + ], + [ + 25, + " req = ActionDispatch::Request.new env\n" + ], + [ + 26, + " req.request_id = make_request_id(req.x_request_id)\n" + ], + [ + 27, + " @app.call(env).tap { |_status, headers, _body| headers[X_REQUEST_ID] = req.request_id }\n" + ], + [ + 28, + " end\n" + ], + [ + 29, + "\n" + ], + [ + 30, + " private\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "call", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/actionpack-5.2.3/lib/action_dispatch/middleware/remote_ip.rb", + "inApp": false, + "lineNo": 81, + "module": null, + "filename": "action_dispatch/middleware/remote_ip.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 78, + " def call(env)\n" + ], + [ + 79, + " req = ActionDispatch::Request.new env\n" + ], + [ + 80, + " req.remote_ip = GetIp.new(req, check_ip, proxies)\n" + ], + [ + 81, + " @app.call(req.env)\n" + ], + [ + 82, + " end\n" + ], + [ + 83, + "\n" + ], + [ + 84, + " # The GetIp class exists as a way to defer processing of the request data\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "call", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/sprockets-rails-3.2.1/lib/sprockets/rails/quiet_assets.rb", + "inApp": false, + "lineNo": 13, + "module": null, + "filename": "sprockets/rails/quiet_assets.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 10, + " if env['PATH_INFO'] =~ @assets_regex\n" + ], + [ + 11, + " ::Rails.logger.silence { @app.call(env) }\n" + ], + [ + 12, + " else\n" + ], + [ + 13, + " @app.call(env)\n" + ], + [ + 14, + " end\n" + ], + [ + 15, + " end\n" + ], + [ + 16, + " end\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "call", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/railties-5.2.3/lib/rails/rack/logger.rb", + "inApp": false, + "lineNo": 26, + "module": null, + "filename": "rails/rack/logger.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 23, + " request = ActionDispatch::Request.new(env)\n" + ], + [ + 24, + "\n" + ], + [ + 25, + " if logger.respond_to?(:tagged)\n" + ], + [ + 26, + " logger.tagged(compute_tags(request)) { call_app(request, env) }\n" + ], + [ + 27, + " else\n" + ], + [ + 28, + " call_app(request, env)\n" + ], + [ + 29, + " end\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "tagged", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/activesupport-5.2.3/lib/active_support/tagged_logging.rb", + "inApp": false, + "lineNo": 71, + "module": null, + "filename": "active_support/tagged_logging.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 68, + " delegate :push_tags, :pop_tags, :clear_tags!, to: :formatter\n" + ], + [ + 69, + "\n" + ], + [ + 70, + " def tagged(*tags)\n" + ], + [ + 71, + " formatter.tagged(*tags) { yield self }\n" + ], + [ + 72, + " end\n" + ], + [ + 73, + "\n" + ], + [ + 74, + " def flush\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "tagged", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/activesupport-5.2.3/lib/active_support/tagged_logging.rb", + "inApp": false, + "lineNo": 28, + "module": null, + "filename": "active_support/tagged_logging.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 25, + "\n" + ], + [ + 26, + " def tagged(*tags)\n" + ], + [ + 27, + " new_tags = push_tags(*tags)\n" + ], + [ + 28, + " yield self\n" + ], + [ + 29, + " ensure\n" + ], + [ + 30, + " pop_tags(new_tags.size)\n" + ], + [ + 31, + " end\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "block in tagged", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/activesupport-5.2.3/lib/active_support/tagged_logging.rb", + "inApp": false, + "lineNo": 71, + "module": null, + "filename": "active_support/tagged_logging.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 68, + " delegate :push_tags, :pop_tags, :clear_tags!, to: :formatter\n" + ], + [ + 69, + "\n" + ], + [ + 70, + " def tagged(*tags)\n" + ], + [ + 71, + " formatter.tagged(*tags) { yield self }\n" + ], + [ + 72, + " end\n" + ], + [ + 73, + "\n" + ], + [ + 74, + " def flush\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "block in call", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/railties-5.2.3/lib/rails/rack/logger.rb", + "inApp": false, + "lineNo": 26, + "module": null, + "filename": "rails/rack/logger.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 23, + " request = ActionDispatch::Request.new(env)\n" + ], + [ + 24, + "\n" + ], + [ + 25, + " if logger.respond_to?(:tagged)\n" + ], + [ + 26, + " logger.tagged(compute_tags(request)) { call_app(request, env) }\n" + ], + [ + 27, + " else\n" + ], + [ + 28, + " call_app(request, env)\n" + ], + [ + 29, + " end\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "call_app", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/railties-5.2.3/lib/rails/rack/logger.rb", + "inApp": false, + "lineNo": 38, + "module": null, + "filename": "rails/rack/logger.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 38, + " status, headers, body = @app.call(env)\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "call", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/actionpack-5.2.3/lib/action_dispatch/middleware/show_exceptions.rb", + "inApp": false, + "lineNo": 33, + "module": null, + "filename": "action_dispatch/middleware/show_exceptions.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 33, + " @app.call(env)\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "call", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/web-console-3.7.0/lib/web_console/middleware.rb", + "inApp": false, + "lineNo": 20, + "module": null, + "filename": "web_console/middleware.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 20, + " app_exception = catch :app_exception do\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "catch", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/web-console-3.7.0/lib/web_console/middleware.rb", + "inApp": false, + "lineNo": 20, + "module": null, + "filename": "web_console/middleware.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 17, + " end\n" + ], + [ + 18, + "\n" + ], + [ + 19, + " def call(env)\n" + ], + [ + 20, + " app_exception = catch :app_exception do\n" + ], + [ + 21, + " request = create_regular_or_whiny_request(env)\n" + ], + [ + 22, + " return call_app(env) unless request.from_whitelisted_ip?\n" + ], + [ + 23, + "\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "block in call", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/web-console-3.7.0/lib/web_console/middleware.rb", + "inApp": false, + "lineNo": 30, + "module": null, + "filename": "web_console/middleware.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 27, + " return change_stack_trace(id, request)\n" + ], + [ + 28, + " end\n" + ], + [ + 29, + "\n" + ], + [ + 30, + " status, headers, body = call_app(env)\n" + ], + [ + 31, + "\n" + ], + [ + 32, + " if (session = Session.from(Thread.current)) && acceptable_content_type?(headers)\n" + ], + [ + 33, + " headers[\"X-Web-Console-Session-Id\"] = session.id\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "call_app", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/web-console-3.7.0/lib/web_console/middleware.rb", + "inApp": false, + "lineNo": 135, + "module": null, + "filename": "web_console/middleware.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 132, + " end\n" + ], + [ + 133, + "\n" + ], + [ + 134, + " def call_app(env)\n" + ], + [ + 135, + " @app.call(env)\n" + ], + [ + 136, + " rescue => e\n" + ], + [ + 137, + " throw :app_exception, e\n" + ], + [ + 138, + " end\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "call", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/actionpack-5.2.3/lib/action_dispatch/middleware/debug_exceptions.rb", + "inApp": false, + "lineNo": 61, + "module": null, + "filename": "action_dispatch/middleware/debug_exceptions.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 58, + "\n" + ], + [ + 59, + " def call(env)\n" + ], + [ + 60, + " request = ActionDispatch::Request.new env\n" + ], + [ + 61, + " _, headers, body = response = @app.call(env)\n" + ], + [ + 62, + "\n" + ], + [ + 63, + " if headers[\"X-Cascade\"] == \"pass\"\n" + ], + [ + 64, + " body.close if body.respond_to?(:close)\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "call", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/actionpack-5.2.3/lib/action_dispatch/middleware/executor.rb", + "inApp": false, + "lineNo": 14, + "module": null, + "filename": "action_dispatch/middleware/executor.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 11, + " def call(env)\n" + ], + [ + 12, + " state = @executor.run!\n" + ], + [ + 13, + " begin\n" + ], + [ + 14, + " response = @app.call(env)\n" + ], + [ + 15, + " returned = response << ::Rack::BodyProxy.new(response.pop) { state.complete! }\n" + ], + [ + 16, + " ensure\n" + ], + [ + 17, + " state.complete! unless returned\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "call", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/actionpack-5.2.3/lib/action_dispatch/middleware/callbacks.rb", + "inApp": false, + "lineNo": 26, + "module": null, + "filename": "action_dispatch/middleware/callbacks.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 23, + "\n" + ], + [ + 24, + " def call(env)\n" + ], + [ + 25, + " error = nil\n" + ], + [ + 26, + " result = run_callbacks :call do\n" + ], + [ + 27, + " begin\n" + ], + [ + 28, + " @app.call(env)\n" + ], + [ + 29, + " rescue => error\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "run_callbacks", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/activesupport-5.2.3/lib/active_support/callbacks.rb", + "inApp": false, + "lineNo": 98, + "module": null, + "filename": "active_support/callbacks.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 95, + " callbacks = __callbacks[kind.to_sym]\n" + ], + [ + 96, + "\n" + ], + [ + 97, + " if callbacks.empty?\n" + ], + [ + 98, + " yield if block_given?\n" + ], + [ + 99, + " else\n" + ], + [ + 100, + " env = Filters::Environment.new(self, false, nil)\n" + ], + [ + 101, + " next_sequence = callbacks.compile\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "block in call", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/actionpack-5.2.3/lib/action_dispatch/middleware/callbacks.rb", + "inApp": false, + "lineNo": 28, + "module": null, + "filename": "action_dispatch/middleware/callbacks.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 25, + " error = nil\n" + ], + [ + 26, + " result = run_callbacks :call do\n" + ], + [ + 27, + " begin\n" + ], + [ + 28, + " @app.call(env)\n" + ], + [ + 29, + " rescue => error\n" + ], + [ + 30, + " end\n" + ], + [ + 31, + " end\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "call", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/activerecord-5.2.3/lib/active_record/migration.rb", + "inApp": false, + "lineNo": 554, + "module": null, + "filename": "active_record/migration.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 551, + " end\n" + ], + [ + 552, + "\n" + ], + [ + 553, + " def call(env)\n" + ], + [ + 554, + " mtime = ActiveRecord::Base.connection.migration_context.last_migration.mtime.to_i\n" + ], + [ + 555, + " if @last_check < mtime\n" + ], + [ + 556, + " ActiveRecord::Migration.check_pending!(connection)\n" + ], + [ + 557, + " @last_check = mtime\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "connection", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/activerecord-5.2.3/lib/active_record/connection_handling.rb", + "inApp": false, + "lineNo": 90, + "module": null, + "filename": "active_record/connection_handling.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 87, + " # also be used to \"borrow\" the connection to do database work unrelated\n" + ], + [ + 88, + " # to any of the specific Active Records.\n" + ], + [ + 89, + " def connection\n" + ], + [ + 90, + " retrieve_connection\n" + ], + [ + 91, + " end\n" + ], + [ + 92, + "\n" + ], + [ + 93, + " attr_writer :connection_specification_name\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "retrieve_connection", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/activerecord-5.2.3/lib/active_record/connection_handling.rb", + "inApp": false, + "lineNo": 118, + "module": null, + "filename": "active_record/connection_handling.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 115, + " end\n" + ], + [ + 116, + "\n" + ], + [ + 117, + " def retrieve_connection\n" + ], + [ + 118, + " connection_handler.retrieve_connection(connection_specification_name)\n" + ], + [ + 119, + " end\n" + ], + [ + 120, + "\n" + ], + [ + 121, + " # Returns +true+ if Active Record is connected.\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "retrieve_connection", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/activerecord-5.2.3/lib/active_record/connection_adapters/abstract/connection_pool.rb", + "inApp": false, + "lineNo": 1014, + "module": null, + "filename": "active_record/connection_adapters/abstract/connection_pool.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 1011, + " def retrieve_connection(spec_name) #:nodoc:\n" + ], + [ + 1012, + " pool = retrieve_connection_pool(spec_name)\n" + ], + [ + 1013, + " raise ConnectionNotEstablished, \"No connection pool with '#{spec_name}' found.\" unless pool\n" + ], + [ + 1014, + " pool.connection\n" + ], + [ + 1015, + " end\n" + ], + [ + 1016, + "\n" + ], + [ + 1017, + " # Returns true if a connection that's accessible to this class has\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "connection", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/activerecord-5.2.3/lib/active_record/connection_adapters/abstract/connection_pool.rb", + "inApp": false, + "lineNo": 382, + "module": null, + "filename": "active_record/connection_adapters/abstract/connection_pool.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 379, + " # #connection can be called any number of times; the connection is\n" + ], + [ + 380, + " # held in a cache keyed by a thread.\n" + ], + [ + 381, + " def connection\n" + ], + [ + 382, + " @thread_cached_conns[connection_cache_key(@lock_thread || Thread.current)] ||= checkout\n" + ], + [ + 383, + " end\n" + ], + [ + 384, + "\n" + ], + [ + 385, + " # Returns true if there is an open connection being used for the current thread.\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "checkout", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/activerecord-5.2.3/lib/active_record/connection_adapters/abstract/connection_pool.rb", + "inApp": false, + "lineNo": 523, + "module": null, + "filename": "active_record/connection_adapters/abstract/connection_pool.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 520, + " # Raises:\n" + ], + [ + 521, + " # - ActiveRecord::ConnectionTimeoutError no connection can be obtained from the pool.\n" + ], + [ + 522, + " def checkout(checkout_timeout = @checkout_timeout)\n" + ], + [ + 523, + " checkout_and_verify(acquire_connection(checkout_timeout))\n" + ], + [ + 524, + " end\n" + ], + [ + 525, + "\n" + ], + [ + 526, + " # Check-in a database connection back into the pool, indicating that you\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "acquire_connection", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/activerecord-5.2.3/lib/active_record/connection_adapters/abstract/connection_pool.rb", + "inApp": false, + "lineNo": 795, + "module": null, + "filename": "active_record/connection_adapters/abstract/connection_pool.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 792, + " # <tt>synchronize { conn.lease }</tt> in this method, but by leaving it to <tt>@available.poll</tt>\n" + ], + [ + 793, + " # and +try_to_checkout_new_connection+ we can piggyback on +synchronize+ sections\n" + ], + [ + 794, + " # of the said methods and avoid an additional +synchronize+ overhead.\n" + ], + [ + 795, + " if conn = @available.poll || try_to_checkout_new_connection\n" + ], + [ + 796, + " conn\n" + ], + [ + 797, + " else\n" + ], + [ + 798, + " reap\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "try_to_checkout_new_connection", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/activerecord-5.2.3/lib/active_record/connection_adapters/abstract/connection_pool.rb", + "inApp": false, + "lineNo": 834, + "module": null, + "filename": "active_record/connection_adapters/abstract/connection_pool.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 831, + " begin\n" + ], + [ + 832, + " # if successfully incremented @now_connecting establish new connection\n" + ], + [ + 833, + " # outside of synchronized section\n" + ], + [ + 834, + " conn = checkout_new_connection\n" + ], + [ + 835, + " ensure\n" + ], + [ + 836, + " synchronize do\n" + ], + [ + 837, + " if conn\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "checkout_new_connection", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/activerecord-5.2.3/lib/active_record/connection_adapters/abstract/connection_pool.rb", + "inApp": false, + "lineNo": 855, + "module": null, + "filename": "active_record/connection_adapters/abstract/connection_pool.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 852, + "\n" + ], + [ + 853, + " def checkout_new_connection\n" + ], + [ + 854, + " raise ConnectionNotEstablished unless @automatic_reconnect\n" + ], + [ + 855, + " new_connection\n" + ], + [ + 856, + " end\n" + ], + [ + 857, + "\n" + ], + [ + 858, + " def checkout_and_verify(c)\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "new_connection", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/activerecord-5.2.3/lib/active_record/connection_adapters/abstract/connection_pool.rb", + "inApp": false, + "lineNo": 811, + "module": null, + "filename": "active_record/connection_adapters/abstract/connection_pool.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 808, + " alias_method :release, :remove_connection_from_thread_cache\n" + ], + [ + 809, + "\n" + ], + [ + 810, + " def new_connection\n" + ], + [ + 811, + " Base.send(spec.adapter_method, spec.config).tap do |conn|\n" + ], + [ + 812, + " conn.schema_cache = schema_cache.dup if schema_cache\n" + ], + [ + 813, + " end\n" + ], + [ + 814, + " end\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "postgresql_connection", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/activerecord-5.2.3/lib/active_record/connection_adapters/postgresql_adapter.rb", + "inApp": false, + "lineNo": 48, + "module": null, + "filename": "active_record/connection_adapters/postgresql_adapter.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 45, + "\n" + ], + [ + 46, + " # The postgres drivers don't allow the creation of an unconnected PG::Connection object,\n" + ], + [ + 47, + " # so just pass a nil connection object for the time being.\n" + ], + [ + 48, + " ConnectionAdapters::PostgreSQLAdapter.new(nil, logger, conn_params, config)\n" + ], + [ + 49, + " end\n" + ], + [ + 50, + " end\n" + ], + [ + 51, + "\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "new", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/activerecord-5.2.3/lib/active_record/connection_adapters/postgresql_adapter.rb", + "inApp": false, + "lineNo": 48, + "module": null, + "filename": "active_record/connection_adapters/postgresql_adapter.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 45, + "\n" + ], + [ + 46, + " # The postgres drivers don't allow the creation of an unconnected PG::Connection object,\n" + ], + [ + 47, + " # so just pass a nil connection object for the time being.\n" + ], + [ + 48, + " ConnectionAdapters::PostgreSQLAdapter.new(nil, logger, conn_params, config)\n" + ], + [ + 49, + " end\n" + ], + [ + 50, + " end\n" + ], + [ + 51, + "\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "initialize", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/activerecord-5.2.3/lib/active_record/connection_adapters/postgresql_adapter.rb", + "inApp": false, + "lineNo": 223, + "module": null, + "filename": "active_record/connection_adapters/postgresql_adapter.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 220, + " @local_tz = nil\n" + ], + [ + 221, + " @max_identifier_length = nil\n" + ], + [ + 222, + "\n" + ], + [ + 223, + " connect\n" + ], + [ + 224, + " add_pg_encoders\n" + ], + [ + 225, + " @statements = StatementPool.new @connection,\n" + ], + [ + 226, + " self.class.type_cast_config_to_integer(config[:statement_limit])\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "connect", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/activerecord-5.2.3/lib/active_record/connection_adapters/postgresql_adapter.rb", + "inApp": false, + "lineNo": 692, + "module": null, + "filename": "active_record/connection_adapters/postgresql_adapter.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 689, + " # Connects to a PostgreSQL server and sets up the adapter depending on the\n" + ], + [ + 690, + " # connected server's characteristics.\n" + ], + [ + 691, + " def connect\n" + ], + [ + 692, + " @connection = PG.connect(@connection_parameters)\n" + ], + [ + 693, + " configure_connection\n" + ], + [ + 694, + " rescue ::PG::Error => error\n" + ], + [ + 695, + " if error.message.include?(\"does not exist\")\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "connect", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/pg-1.1.4/lib/pg.rb", + "inApp": false, + "lineNo": 56, + "module": null, + "filename": "pg.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 53, + "\n" + ], + [ + 54, + "\t### Convenience alias for PG::Connection.new.\n" + ], + [ + 55, + "\tdef self::connect( *args )\n" + ], + [ + 56, + "\t\treturn PG::Connection.new( *args )\n" + ], + [ + 57, + "\tend\n" + ], + [ + 58, + "\n" + ], + [ + 59, + "\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "new", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/pg-1.1.4/lib/pg.rb", + "inApp": false, + "lineNo": 56, + "module": null, + "filename": "pg.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 53, + "\n" + ], + [ + 54, + "\t### Convenience alias for PG::Connection.new.\n" + ], + [ + 55, + "\tdef self::connect( *args )\n" + ], + [ + 56, + "\t\treturn PG::Connection.new( *args )\n" + ], + [ + 57, + "\tend\n" + ], + [ + 58, + "\n" + ], + [ + 59, + "\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "initialize", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/pg-1.1.4/lib/pg.rb", + "inApp": false, + "lineNo": 56, + "module": null, + "filename": "pg.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 53, + "\n" + ], + [ + 54, + "\t### Convenience alias for PG::Connection.new.\n" + ], + [ + 55, + "\tdef self::connect( *args )\n" + ], + [ + 56, + "\t\treturn PG::Connection.new( *args )\n" + ], + [ + 57, + "\tend\n" + ], + [ + 58, + "\n" + ], + [ + 59, + "\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + } + ], + "framesOmitted": null, + "registers": null, + "hasSystemFrames": false + }, + "module": "PG", + "rawStacktrace": null, + "mechanism": null, + "threadId": null, + "value": "FATAL: database \"test_development\" does not exist\n", + "type": "PG::ConnectionBad" + }, + { + "stacktrace": { + "frames": [ + { + "function": "block in spawn_thread", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/puma-3.12.1/lib/puma/thread_pool.rb", + "inApp": false, + "lineNo": 135, + "module": null, + "filename": "puma/thread_pool.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 132, + " end\n" + ], + [ + 133, + "\n" + ], + [ + 134, + " begin\n" + ], + [ + 135, + " block.call(work, *extra)\n" + ], + [ + 136, + " rescue Exception => e\n" + ], + [ + 137, + " STDERR.puts \"Error reached top of thread-pool: #{e.message} (#{e.class})\"\n" + ], + [ + 138, + " end\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "block in run", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/puma-3.12.1/lib/puma/server.rb", + "inApp": false, + "lineNo": 334, + "module": null, + "filename": "puma/server.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 331, + " client.close\n" + ], + [ + 332, + " else\n" + ], + [ + 333, + " if process_now\n" + ], + [ + 334, + " process_client client, buffer\n" + ], + [ + 335, + " else\n" + ], + [ + 336, + " client.set_timeout @first_data_timeout\n" + ], + [ + 337, + " @reactor.add client\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "process_client", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/puma-3.12.1/lib/puma/server.rb", + "inApp": false, + "lineNo": 474, + "module": null, + "filename": "puma/server.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 471, + " close_socket = true\n" + ], + [ + 472, + "\n" + ], + [ + 473, + " while true\n" + ], + [ + 474, + " case handle_request(client, buffer)\n" + ], + [ + 475, + " when false\n" + ], + [ + 476, + " return\n" + ], + [ + 477, + " when :async\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "handle_request", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/puma-3.12.1/lib/puma/server.rb", + "inApp": false, + "lineNo": 660, + "module": null, + "filename": "puma/server.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 657, + "\n" + ], + [ + 658, + " begin\n" + ], + [ + 659, + " begin\n" + ], + [ + 660, + " status, headers, res_body = @app.call(env)\n" + ], + [ + 661, + "\n" + ], + [ + 662, + " return :async if req.hijacked\n" + ], + [ + 663, + "\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "call", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/puma-3.12.1/lib/puma/configuration.rb", + "inApp": false, + "lineNo": 227, + "module": null, + "filename": "puma/configuration.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 224, + "\n" + ], + [ + 225, + " def call(env)\n" + ], + [ + 226, + " env[Const::PUMA_CONFIG] = @config\n" + ], + [ + 227, + " @app.call(env)\n" + ], + [ + 228, + " end\n" + ], + [ + 229, + " end\n" + ], + [ + 230, + "\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "call", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/prometheus-client-mmap-0.9.8/lib/prometheus/client/rack/collector.rb", + "inApp": false, + "lineNo": 24, + "module": null, + "filename": "prometheus/client/rack/collector.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 21, + " end\n" + ], + [ + 22, + "\n" + ], + [ + 23, + " def call(env) # :nodoc:\n" + ], + [ + 24, + " trace(env) { @app.call(env) }\n" + ], + [ + 25, + " end\n" + ], + [ + 26, + "\n" + ], + [ + 27, + " protected\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "trace", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/prometheus-client-mmap-0.9.8/lib/prometheus/client/rack/collector.rb", + "inApp": false, + "lineNo": 61, + "module": null, + "filename": "prometheus/client/rack/collector.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 58, + "\n" + ], + [ + 59, + " def trace(env)\n" + ], + [ + 60, + " start = Time.now\n" + ], + [ + 61, + " yield.tap do |response|\n" + ], + [ + 62, + " duration = (Time.now - start).to_f\n" + ], + [ + 63, + " record(labels(env, response), duration)\n" + ], + [ + 64, + " end\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "block in call", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/prometheus-client-mmap-0.9.8/lib/prometheus/client/rack/collector.rb", + "inApp": false, + "lineNo": 24, + "module": null, + "filename": "prometheus/client/rack/collector.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 21, + " end\n" + ], + [ + 22, + "\n" + ], + [ + 23, + " def call(env) # :nodoc:\n" + ], + [ + 24, + " trace(env) { @app.call(env) }\n" + ], + [ + 25, + " end\n" + ], + [ + 26, + "\n" + ], + [ + 27, + " protected\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "call", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/prometheus-client-mmap-0.9.8/lib/prometheus/client/rack/exporter.rb", + "inApp": false, + "lineNo": 29, + "module": null, + "filename": "prometheus/client/rack/exporter.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 26, + " format = negotiate(env['HTTP_ACCEPT'], @acceptable)\n" + ], + [ + 27, + " format ? respond_with(format) : not_acceptable(FORMATS)\n" + ], + [ + 28, + " else\n" + ], + [ + 29, + " @app.call(env)\n" + ], + [ + 30, + " end\n" + ], + [ + 31, + " end\n" + ], + [ + 32, + "\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "call", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/railties-5.2.3/lib/rails/engine.rb", + "inApp": false, + "lineNo": 524, + "module": null, + "filename": "rails/engine.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 521, + " # Define the Rack API for this engine.\n" + ], + [ + 522, + " def call(env)\n" + ], + [ + 523, + " req = build_request env\n" + ], + [ + 524, + " app.call req.env\n" + ], + [ + 525, + " end\n" + ], + [ + 526, + "\n" + ], + [ + 527, + " # Defines additional Rack env configuration that is added on each call.\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "call", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/sentry-raven-2.12.3/lib/raven/integrations/rack.rb", + "inApp": false, + "lineNo": 51, + "module": null, + "filename": "raven/integrations/rack.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 48, + " Raven.context.transaction.push(env[\"PATH_INFO\"]) if env[\"PATH_INFO\"]\n" + ], + [ + 49, + "\n" + ], + [ + 50, + " begin\n" + ], + [ + 51, + " response = @app.call(env)\n" + ], + [ + 52, + " rescue Error\n" + ], + [ + 53, + " raise # Don't capture Raven errors\n" + ], + [ + 54, + " rescue Exception => e\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "call", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/rack-2.0.7/lib/rack/sendfile.rb", + "inApp": false, + "lineNo": 111, + "module": null, + "filename": "rack/sendfile.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 108, + " end\n" + ], + [ + 109, + "\n" + ], + [ + 110, + " def call(env)\n" + ], + [ + 111, + " status, headers, body = @app.call(env)\n" + ], + [ + 112, + " if body.respond_to?(:to_path)\n" + ], + [ + 113, + " case type = variation(env)\n" + ], + [ + 114, + " when 'X-Accel-Redirect'\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "call", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/actionpack-5.2.3/lib/action_dispatch/middleware/static.rb", + "inApp": false, + "lineNo": 127, + "module": null, + "filename": "action_dispatch/middleware/static.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 124, + " end\n" + ], + [ + 125, + " end\n" + ], + [ + 126, + "\n" + ], + [ + 127, + " @app.call(req.env)\n" + ], + [ + 128, + " end\n" + ], + [ + 129, + " end\n" + ], + [ + 130, + "end\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "call", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/actionpack-5.2.3/lib/action_dispatch/middleware/executor.rb", + "inApp": false, + "lineNo": 14, + "module": null, + "filename": "action_dispatch/middleware/executor.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 11, + " def call(env)\n" + ], + [ + 12, + " state = @executor.run!\n" + ], + [ + 13, + " begin\n" + ], + [ + 14, + " response = @app.call(env)\n" + ], + [ + 15, + " returned = response << ::Rack::BodyProxy.new(response.pop) { state.complete! }\n" + ], + [ + 16, + " ensure\n" + ], + [ + 17, + " state.complete! unless returned\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "call", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/activesupport-5.2.3/lib/active_support/cache/strategy/local_cache_middleware.rb", + "inApp": false, + "lineNo": 29, + "module": null, + "filename": "active_support/cache/strategy/local_cache_middleware.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 26, + "\n" + ], + [ + 27, + " def call(env)\n" + ], + [ + 28, + " LocalCacheRegistry.set_cache_for(local_cache_key, LocalStore.new)\n" + ], + [ + 29, + " response = @app.call(env)\n" + ], + [ + 30, + " response[2] = ::Rack::BodyProxy.new(response[2]) do\n" + ], + [ + 31, + " LocalCacheRegistry.set_cache_for(local_cache_key, nil)\n" + ], + [ + 32, + " end\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "call", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/rack-2.0.7/lib/rack/runtime.rb", + "inApp": false, + "lineNo": 22, + "module": null, + "filename": "rack/runtime.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 19, + "\n" + ], + [ + 20, + " def call(env)\n" + ], + [ + 21, + " start_time = Utils.clock_time\n" + ], + [ + 22, + " status, headers, body = @app.call(env)\n" + ], + [ + 23, + " request_time = Utils.clock_time - start_time\n" + ], + [ + 24, + "\n" + ], + [ + 25, + " unless headers.has_key?(@header_name)\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "call", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/rack-2.0.7/lib/rack/method_override.rb", + "inApp": false, + "lineNo": 22, + "module": null, + "filename": "rack/method_override.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 19, + " end\n" + ], + [ + 20, + " end\n" + ], + [ + 21, + "\n" + ], + [ + 22, + " @app.call(env)\n" + ], + [ + 23, + " end\n" + ], + [ + 24, + "\n" + ], + [ + 25, + " def method_override(env)\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "call", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/actionpack-5.2.3/lib/action_dispatch/middleware/request_id.rb", + "inApp": false, + "lineNo": 27, + "module": null, + "filename": "action_dispatch/middleware/request_id.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 24, + " def call(env)\n" + ], + [ + 25, + " req = ActionDispatch::Request.new env\n" + ], + [ + 26, + " req.request_id = make_request_id(req.x_request_id)\n" + ], + [ + 27, + " @app.call(env).tap { |_status, headers, _body| headers[X_REQUEST_ID] = req.request_id }\n" + ], + [ + 28, + " end\n" + ], + [ + 29, + "\n" + ], + [ + 30, + " private\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "call", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/actionpack-5.2.3/lib/action_dispatch/middleware/remote_ip.rb", + "inApp": false, + "lineNo": 81, + "module": null, + "filename": "action_dispatch/middleware/remote_ip.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 78, + " def call(env)\n" + ], + [ + 79, + " req = ActionDispatch::Request.new env\n" + ], + [ + 80, + " req.remote_ip = GetIp.new(req, check_ip, proxies)\n" + ], + [ + 81, + " @app.call(req.env)\n" + ], + [ + 82, + " end\n" + ], + [ + 83, + "\n" + ], + [ + 84, + " # The GetIp class exists as a way to defer processing of the request data\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "call", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/sprockets-rails-3.2.1/lib/sprockets/rails/quiet_assets.rb", + "inApp": false, + "lineNo": 13, + "module": null, + "filename": "sprockets/rails/quiet_assets.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 10, + " if env['PATH_INFO'] =~ @assets_regex\n" + ], + [ + 11, + " ::Rails.logger.silence { @app.call(env) }\n" + ], + [ + 12, + " else\n" + ], + [ + 13, + " @app.call(env)\n" + ], + [ + 14, + " end\n" + ], + [ + 15, + " end\n" + ], + [ + 16, + " end\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "call", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/railties-5.2.3/lib/rails/rack/logger.rb", + "inApp": false, + "lineNo": 26, + "module": null, + "filename": "rails/rack/logger.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 23, + " request = ActionDispatch::Request.new(env)\n" + ], + [ + 24, + "\n" + ], + [ + 25, + " if logger.respond_to?(:tagged)\n" + ], + [ + 26, + " logger.tagged(compute_tags(request)) { call_app(request, env) }\n" + ], + [ + 27, + " else\n" + ], + [ + 28, + " call_app(request, env)\n" + ], + [ + 29, + " end\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "tagged", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/activesupport-5.2.3/lib/active_support/tagged_logging.rb", + "inApp": false, + "lineNo": 71, + "module": null, + "filename": "active_support/tagged_logging.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 68, + " delegate :push_tags, :pop_tags, :clear_tags!, to: :formatter\n" + ], + [ + 69, + "\n" + ], + [ + 70, + " def tagged(*tags)\n" + ], + [ + 71, + " formatter.tagged(*tags) { yield self }\n" + ], + [ + 72, + " end\n" + ], + [ + 73, + "\n" + ], + [ + 74, + " def flush\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "tagged", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/activesupport-5.2.3/lib/active_support/tagged_logging.rb", + "inApp": false, + "lineNo": 28, + "module": null, + "filename": "active_support/tagged_logging.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 25, + "\n" + ], + [ + 26, + " def tagged(*tags)\n" + ], + [ + 27, + " new_tags = push_tags(*tags)\n" + ], + [ + 28, + " yield self\n" + ], + [ + 29, + " ensure\n" + ], + [ + 30, + " pop_tags(new_tags.size)\n" + ], + [ + 31, + " end\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "block in tagged", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/activesupport-5.2.3/lib/active_support/tagged_logging.rb", + "inApp": false, + "lineNo": 71, + "module": null, + "filename": "active_support/tagged_logging.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 68, + " delegate :push_tags, :pop_tags, :clear_tags!, to: :formatter\n" + ], + [ + 69, + "\n" + ], + [ + 70, + " def tagged(*tags)\n" + ], + [ + 71, + " formatter.tagged(*tags) { yield self }\n" + ], + [ + 72, + " end\n" + ], + [ + 73, + "\n" + ], + [ + 74, + " def flush\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "block in call", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/railties-5.2.3/lib/rails/rack/logger.rb", + "inApp": false, + "lineNo": 26, + "module": null, + "filename": "rails/rack/logger.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 23, + " request = ActionDispatch::Request.new(env)\n" + ], + [ + 24, + "\n" + ], + [ + 25, + " if logger.respond_to?(:tagged)\n" + ], + [ + 26, + " logger.tagged(compute_tags(request)) { call_app(request, env) }\n" + ], + [ + 27, + " else\n" + ], + [ + 28, + " call_app(request, env)\n" + ], + [ + 29, + " end\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "call_app", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/railties-5.2.3/lib/rails/rack/logger.rb", + "inApp": false, + "lineNo": 38, + "module": null, + "filename": "rails/rack/logger.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 38, + " status, headers, body = @app.call(env)\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "call", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/actionpack-5.2.3/lib/action_dispatch/middleware/show_exceptions.rb", + "inApp": false, + "lineNo": 33, + "module": null, + "filename": "action_dispatch/middleware/show_exceptions.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 30, + "\n" + ], + [ + 31, + " def call(env)\n" + ], + [ + 32, + " request = ActionDispatch::Request.new env\n" + ], + [ + 33, + " @app.call(env)\n" + ], + [ + 34, + " rescue Exception => exception\n" + ], + [ + 35, + " if request.show_exceptions?\n" + ], + [ + 36, + " render_exception(request, exception)\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "call", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/web-console-3.7.0/lib/web_console/middleware.rb", + "inApp": false, + "lineNo": 20, + "module": null, + "filename": "web_console/middleware.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 17, + " end\n" + ], + [ + 18, + "\n" + ], + [ + 19, + " def call(env)\n" + ], + [ + 20, + " app_exception = catch :app_exception do\n" + ], + [ + 21, + " request = create_regular_or_whiny_request(env)\n" + ], + [ + 22, + " return call_app(env) unless request.from_whitelisted_ip?\n" + ], + [ + 23, + "\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "catch", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/web-console-3.7.0/lib/web_console/middleware.rb", + "inApp": false, + "lineNo": 20, + "module": null, + "filename": "web_console/middleware.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 17, + " end\n" + ], + [ + 18, + "\n" + ], + [ + 19, + " def call(env)\n" + ], + [ + 20, + " app_exception = catch :app_exception do\n" + ], + [ + 21, + " request = create_regular_or_whiny_request(env)\n" + ], + [ + 22, + " return call_app(env) unless request.from_whitelisted_ip?\n" + ], + [ + 23, + "\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "block in call", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/web-console-3.7.0/lib/web_console/middleware.rb", + "inApp": false, + "lineNo": 30, + "module": null, + "filename": "web_console/middleware.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 27, + " return change_stack_trace(id, request)\n" + ], + [ + 28, + " end\n" + ], + [ + 29, + "\n" + ], + [ + 30, + " status, headers, body = call_app(env)\n" + ], + [ + 31, + "\n" + ], + [ + 32, + " if (session = Session.from(Thread.current)) && acceptable_content_type?(headers)\n" + ], + [ + 33, + " headers[\"X-Web-Console-Session-Id\"] = session.id\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "call_app", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/web-console-3.7.0/lib/web_console/middleware.rb", + "inApp": false, + "lineNo": 135, + "module": null, + "filename": "web_console/middleware.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 132, + " end\n" + ], + [ + 133, + "\n" + ], + [ + 134, + " def call_app(env)\n" + ], + [ + 135, + " @app.call(env)\n" + ], + [ + 136, + " rescue => e\n" + ], + [ + 137, + " throw :app_exception, e\n" + ], + [ + 138, + " end\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "call", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/actionpack-5.2.3/lib/action_dispatch/middleware/debug_exceptions.rb", + "inApp": false, + "lineNo": 61, + "module": null, + "filename": "action_dispatch/middleware/debug_exceptions.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 58, + "\n" + ], + [ + 59, + " def call(env)\n" + ], + [ + 60, + " request = ActionDispatch::Request.new env\n" + ], + [ + 61, + " _, headers, body = response = @app.call(env)\n" + ], + [ + 62, + "\n" + ], + [ + 63, + " if headers[\"X-Cascade\"] == \"pass\"\n" + ], + [ + 64, + " body.close if body.respond_to?(:close)\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "call", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/actionpack-5.2.3/lib/action_dispatch/middleware/executor.rb", + "inApp": false, + "lineNo": 14, + "module": null, + "filename": "action_dispatch/middleware/executor.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 11, + " def call(env)\n" + ], + [ + 12, + " state = @executor.run!\n" + ], + [ + 13, + " begin\n" + ], + [ + 14, + " response = @app.call(env)\n" + ], + [ + 15, + " returned = response << ::Rack::BodyProxy.new(response.pop) { state.complete! }\n" + ], + [ + 16, + " ensure\n" + ], + [ + 17, + " state.complete! unless returned\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "call", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/actionpack-5.2.3/lib/action_dispatch/middleware/callbacks.rb", + "inApp": false, + "lineNo": 26, + "module": null, + "filename": "action_dispatch/middleware/callbacks.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 23, + "\n" + ], + [ + 24, + " def call(env)\n" + ], + [ + 25, + " error = nil\n" + ], + [ + 26, + " result = run_callbacks :call do\n" + ], + [ + 27, + " begin\n" + ], + [ + 28, + " @app.call(env)\n" + ], + [ + 29, + " rescue => error\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "run_callbacks", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/activesupport-5.2.3/lib/active_support/callbacks.rb", + "inApp": false, + "lineNo": 98, + "module": null, + "filename": "active_support/callbacks.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 95, + " callbacks = __callbacks[kind.to_sym]\n" + ], + [ + 96, + "\n" + ], + [ + 97, + " if callbacks.empty?\n" + ], + [ + 98, + " yield if block_given?\n" + ], + [ + 99, + " else\n" + ], + [ + 100, + " env = Filters::Environment.new(self, false, nil)\n" + ], + [ + 101, + " next_sequence = callbacks.compile\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "block in call", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/actionpack-5.2.3/lib/action_dispatch/middleware/callbacks.rb", + "inApp": false, + "lineNo": 28, + "module": null, + "filename": "action_dispatch/middleware/callbacks.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 25, + " error = nil\n" + ], + [ + 26, + " result = run_callbacks :call do\n" + ], + [ + 27, + " begin\n" + ], + [ + 28, + " @app.call(env)\n" + ], + [ + 29, + " rescue => error\n" + ], + [ + 30, + " end\n" + ], + [ + 31, + " end\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "call", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/activerecord-5.2.3/lib/active_record/migration.rb", + "inApp": false, + "lineNo": 554, + "module": null, + "filename": "active_record/migration.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 551, + " end\n" + ], + [ + 552, + "\n" + ], + [ + 553, + " def call(env)\n" + ], + [ + 554, + " mtime = ActiveRecord::Base.connection.migration_context.last_migration.mtime.to_i\n" + ], + [ + 555, + " if @last_check < mtime\n" + ], + [ + 556, + " ActiveRecord::Migration.check_pending!(connection)\n" + ], + [ + 557, + " @last_check = mtime\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "connection", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/activerecord-5.2.3/lib/active_record/connection_handling.rb", + "inApp": false, + "lineNo": 90, + "module": null, + "filename": "active_record/connection_handling.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 87, + " # also be used to \"borrow\" the connection to do database work unrelated\n" + ], + [ + 88, + " # to any of the specific Active Records.\n" + ], + [ + 89, + " def connection\n" + ], + [ + 90, + " retrieve_connection\n" + ], + [ + 91, + " end\n" + ], + [ + 92, + "\n" + ], + [ + 93, + " attr_writer :connection_specification_name\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "retrieve_connection", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/activerecord-5.2.3/lib/active_record/connection_handling.rb", + "inApp": false, + "lineNo": 118, + "module": null, + "filename": "active_record/connection_handling.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 115, + " end\n" + ], + [ + 116, + "\n" + ], + [ + 117, + " def retrieve_connection\n" + ], + [ + 118, + " connection_handler.retrieve_connection(connection_specification_name)\n" + ], + [ + 119, + " end\n" + ], + [ + 120, + "\n" + ], + [ + 121, + " # Returns +true+ if Active Record is connected.\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "retrieve_connection", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/activerecord-5.2.3/lib/active_record/connection_adapters/abstract/connection_pool.rb", + "inApp": false, + "lineNo": 1014, + "module": null, + "filename": "active_record/connection_adapters/abstract/connection_pool.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 1011, + " def retrieve_connection(spec_name) #:nodoc:\n" + ], + [ + 1012, + " pool = retrieve_connection_pool(spec_name)\n" + ], + [ + 1013, + " raise ConnectionNotEstablished, \"No connection pool with '#{spec_name}' found.\" unless pool\n" + ], + [ + 1014, + " pool.connection\n" + ], + [ + 1015, + " end\n" + ], + [ + 1016, + "\n" + ], + [ + 1017, + " # Returns true if a connection that's accessible to this class has\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "connection", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/activerecord-5.2.3/lib/active_record/connection_adapters/abstract/connection_pool.rb", + "inApp": false, + "lineNo": 382, + "module": null, + "filename": "active_record/connection_adapters/abstract/connection_pool.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 379, + " # #connection can be called any number of times; the connection is\n" + ], + [ + 380, + " # held in a cache keyed by a thread.\n" + ], + [ + 381, + " def connection\n" + ], + [ + 382, + " @thread_cached_conns[connection_cache_key(@lock_thread || Thread.current)] ||= checkout\n" + ], + [ + 383, + " end\n" + ], + [ + 384, + "\n" + ], + [ + 385, + " # Returns true if there is an open connection being used for the current thread.\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "checkout", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/activerecord-5.2.3/lib/active_record/connection_adapters/abstract/connection_pool.rb", + "inApp": false, + "lineNo": 523, + "module": null, + "filename": "active_record/connection_adapters/abstract/connection_pool.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 520, + " # Raises:\n" + ], + [ + 521, + " # - ActiveRecord::ConnectionTimeoutError no connection can be obtained from the pool.\n" + ], + [ + 522, + " def checkout(checkout_timeout = @checkout_timeout)\n" + ], + [ + 523, + " checkout_and_verify(acquire_connection(checkout_timeout))\n" + ], + [ + 524, + " end\n" + ], + [ + 525, + "\n" + ], + [ + 526, + " # Check-in a database connection back into the pool, indicating that you\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "acquire_connection", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/activerecord-5.2.3/lib/active_record/connection_adapters/abstract/connection_pool.rb", + "inApp": false, + "lineNo": 795, + "module": null, + "filename": "active_record/connection_adapters/abstract/connection_pool.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 792, + " # <tt>synchronize { conn.lease }</tt> in this method, but by leaving it to <tt>@available.poll</tt>\n" + ], + [ + 793, + " # and +try_to_checkout_new_connection+ we can piggyback on +synchronize+ sections\n" + ], + [ + 794, + " # of the said methods and avoid an additional +synchronize+ overhead.\n" + ], + [ + 795, + " if conn = @available.poll || try_to_checkout_new_connection\n" + ], + [ + 796, + " conn\n" + ], + [ + 797, + " else\n" + ], + [ + 798, + " reap\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "try_to_checkout_new_connection", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/activerecord-5.2.3/lib/active_record/connection_adapters/abstract/connection_pool.rb", + "inApp": false, + "lineNo": 834, + "module": null, + "filename": "active_record/connection_adapters/abstract/connection_pool.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 831, + " begin\n" + ], + [ + 832, + " # if successfully incremented @now_connecting establish new connection\n" + ], + [ + 833, + " # outside of synchronized section\n" + ], + [ + 834, + " conn = checkout_new_connection\n" + ], + [ + 835, + " ensure\n" + ], + [ + 836, + " synchronize do\n" + ], + [ + 837, + " if conn\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "checkout_new_connection", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/activerecord-5.2.3/lib/active_record/connection_adapters/abstract/connection_pool.rb", + "inApp": false, + "lineNo": 855, + "module": null, + "filename": "active_record/connection_adapters/abstract/connection_pool.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 852, + "\n" + ], + [ + 853, + " def checkout_new_connection\n" + ], + [ + 854, + " raise ConnectionNotEstablished unless @automatic_reconnect\n" + ], + [ + 855, + " new_connection\n" + ], + [ + 856, + " end\n" + ], + [ + 857, + "\n" + ], + [ + 858, + " def checkout_and_verify(c)\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "new_connection", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/activerecord-5.2.3/lib/active_record/connection_adapters/abstract/connection_pool.rb", + "inApp": false, + "lineNo": 811, + "module": null, + "filename": "active_record/connection_adapters/abstract/connection_pool.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 808, + " alias_method :release, :remove_connection_from_thread_cache\n" + ], + [ + 809, + "\n" + ], + [ + 810, + " def new_connection\n" + ], + [ + 811, + " Base.send(spec.adapter_method, spec.config).tap do |conn|\n" + ], + [ + 812, + " conn.schema_cache = schema_cache.dup if schema_cache\n" + ], + [ + 813, + " end\n" + ], + [ + 814, + " end\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "postgresql_connection", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/activerecord-5.2.3/lib/active_record/connection_adapters/postgresql_adapter.rb", + "inApp": false, + "lineNo": 48, + "module": null, + "filename": "active_record/connection_adapters/postgresql_adapter.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 45, + "\n" + ], + [ + 46, + " # The postgres drivers don't allow the creation of an unconnected PG::Connection object,\n" + ], + [ + 47, + " # so just pass a nil connection object for the time being.\n" + ], + [ + 48, + " ConnectionAdapters::PostgreSQLAdapter.new(nil, logger, conn_params, config)\n" + ], + [ + 49, + " end\n" + ], + [ + 50, + " end\n" + ], + [ + 51, + "\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "new", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/activerecord-5.2.3/lib/active_record/connection_adapters/postgresql_adapter.rb", + "inApp": false, + "lineNo": 48, + "module": null, + "filename": "active_record/connection_adapters/postgresql_adapter.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 45, + "\n" + ], + [ + 46, + " # The postgres drivers don't allow the creation of an unconnected PG::Connection object,\n" + ], + [ + 47, + " # so just pass a nil connection object for the time being.\n" + ], + [ + 48, + " ConnectionAdapters::PostgreSQLAdapter.new(nil, logger, conn_params, config)\n" + ], + [ + 49, + " end\n" + ], + [ + 50, + " end\n" + ], + [ + 51, + "\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "initialize", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/activerecord-5.2.3/lib/active_record/connection_adapters/postgresql_adapter.rb", + "inApp": false, + "lineNo": 223, + "module": null, + "filename": "active_record/connection_adapters/postgresql_adapter.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 220, + " @local_tz = nil\n" + ], + [ + 221, + " @max_identifier_length = nil\n" + ], + [ + 222, + "\n" + ], + [ + 223, + " connect\n" + ], + [ + 224, + " add_pg_encoders\n" + ], + [ + 225, + " @statements = StatementPool.new @connection,\n" + ], + [ + 226, + " self.class.type_cast_config_to_integer(config[:statement_limit])\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "connect", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/activerecord-5.2.3/lib/active_record/connection_adapters/postgresql_adapter.rb", + "inApp": false, + "lineNo": 691, + "module": null, + "filename": "active_record/connection_adapters/postgresql_adapter.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 688, + "\n" + ], + [ + 689, + " # Connects to a PostgreSQL server and sets up the adapter depending on the\n" + ], + [ + 690, + " # connected server's characteristics.\n" + ], + [ + 691, + " def connect\n" + ], + [ + 692, + " @connection = PG.connect(@connection_parameters)\n" + ], + [ + 693, + " configure_connection\n" + ], + [ + 694, + " rescue ::PG::Error => error\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + }, + { + "function": "rescue in connect", + "errors": null, + "colNo": null, + "vars": null, + "package": null, + "absPath": "/Users/gitlab/.rvm/gems/ruby-2.6.5/gems/activerecord-5.2.3/lib/active_record/connection_adapters/postgresql_adapter.rb", + "inApp": false, + "lineNo": 696, + "module": null, + "filename": "active_record/connection_adapters/postgresql_adapter.rb", + "platform": null, + "instructionAddr": null, + "context": [ + [ + 693, + " configure_connection\n" + ], + [ + 694, + " rescue ::PG::Error => error\n" + ], + [ + 695, + " if error.message.include?(\"does not exist\")\n" + ], + [ + 696, + " raise ActiveRecord::NoDatabaseError\n" + ], + [ + 697, + " else\n" + ], + [ + 698, + " raise\n" + ], + [ + 699, + " end\n" + ] + ], + "symbolAddr": null, + "trust": null, + "symbol": null, + "rawFunction": null + } + ], + "framesOmitted": null, + "registers": null, + "hasSystemFrames": false + }, + "module": "ActiveRecord", + "rawStacktrace": null, + "mechanism": null, + "threadId": null, + "value": "FATAL: database \"test_development\" does not exist\n", + "type": "ActiveRecord::NoDatabaseError" + } + ], + "excOmitted": null, + "hasSystemFrames": false + } + }, + { + "type": "request", + "data": { + "fragment": null, + "cookies": [], + "inferredContentType": null, + "env": { + "SERVER_PORT": "3001", + "SERVER_NAME": "localhost", + "REMOTE_ADDR": "::1" + }, + "headers": [ + [ + "Accept", + "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3" + ], + [ + "Accept-Encoding", + "gzip, deflate, br" + ], + [ + "Accept-Language", + "en-GB,en-US;q=0.9,en;q=0.8" + ], + [ + "Cache-Control", + "max-age=0" + ], + [ + "Connection", + "keep-alive" + ], + [ + "Host", + "localhost:3001" + ], + [ + "Sec-Fetch-Mode", + "navigate" + ], + [ + "Sec-Fetch-Site", + "none" + ], + [ + "Sec-Fetch-User", + "?1" + ], + [ + "Upgrade-Insecure-Requests", + "1" + ], + [ + "User-Agent", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/78.0.3904.108 Safari/537.36" + ] + ], + "url": "http://localhost:3001/", + "query": [], + "data": null, + "method": "GET" + } + } + ], + "packages": { + "coffee-script": "2.4.1", + "uglifier": "4.1.20", + "ffi": "1.11.1", + "actioncable": "5.2.3", + "io-like": "0.3.0", + "rb-inotify": "0.10.0", + "spring": "2.1.0", + "loofah": "2.2.3", + "selenium-webdriver": "3.142.3", + "marcel": "0.3.3", + "sass-listen": "4.0.0", + "nokogiri": "1.10.4", + "activestorage": "5.2.3", + "activejob": "5.2.3", + "mimemagic": "0.3.3", + "faraday": "0.17.1", + "execjs": "2.7.0", + "activesupport": "5.2.3", + "rails-html-sanitizer": "1.2.0", + "byebug": "11.0.1", + "xpath": "3.2.0", + "msgpack": "1.3.1", + "childprocess": "1.0.1", + "rails-dom-testing": "2.0.3", + "public_suffix": "3.1.1", + "mini_mime": "1.0.2", + "arel": "9.0.0", + "coffee-rails": "4.2.2", + "bundler": "1.17.3", + "rails": "5.2.3", + "globalid": "0.4.2", + "sentry-raven": "2.12.3", + "concurrent-ruby": "1.1.5", + "capybara": "3.28.0", + "regexp_parser": "1.6.0", + "sprockets-rails": "3.2.1", + "tzinfo": "1.2.5", + "mail": "2.7.1", + "actionview": "5.2.3", + "rubyzip": "1.2.3", + "coffee-script-source": "1.12.2", + "listen": "3.1.5", + "i18n": "1.6.0", + "erubi": "1.8.0", + "rake": "12.3.3", + "nio4r": "2.4.0", + "activemodel": "5.2.3", + "web-console": "3.7.0", + "ruby_dep": "1.5.0", + "turbolinks": "5.2.0", + "archive-zip": "0.12.0", + "method_source": "0.9.2", + "minitest": "5.11.3", + "puma": "3.12.1", + "sass-rails": "5.1.0", + "chromedriver-helper": "2.1.1", + "sprockets": "3.7.2", + "bindex": "0.8.1", + "actionmailer": "5.2.3", + "rack-test": "1.1.0", + "bootsnap": "1.4.4", + "railties": "5.2.3", + "mini_portile2": "2.4.0", + "crass": "1.0.4", + "websocket-extensions": "0.1.4", + "multipart-post": "2.1.1", + "rb-fsevent": "0.10.3", + "jbuilder": "2.9.1", + "pg": "1.1.4", + "sass": "3.7.4", + "activerecord": "5.2.3", + "builder": "3.2.3", + "spring-watcher-listen": "2.0.1", + "websocket-driver": "0.7.1", + "thor": "0.20.3", + "thread_safe": "0.3.6", + "addressable": "2.6.0", + "prometheus-client-mmap": "0.9.8", + "tilt": "2.0.9", + "actionpack": "5.2.3", + "rack": "2.0.7", + "turbolinks-source": "5.2.0" + }, + "sdk": { + "version": "2.12.3", + "name": "raven-ruby" + }, + "_meta": { + "user": null, + "context": null, + "entries": { + "1": { + "data": { + "": null, + "cookies": { + "": { + "err": [ + [ + "invalid_data", + { + "reason": "the cookie is missing a name/value pair" + } + ] + ], + "val": "********" + } + }, + "url": null, + "headers": null, + "env": null, + "query": null, + "data": null, + "method": null + } + } + }, + "contexts": null, + "message": null, + "packages": null, + "tags": {}, + "sdk": null + }, + "contexts": { + "browser": { + "version": "78.0.3904", + "type": "browser", + "name": "Chrome" + }, + "client_os": { + "version": "10.15.1", + "type": "os", + "name": "Mac OS X" + } + }, + "fingerprints": [ + "6aa133ea51857634f2d113de52b5cc61", + "e1613eeb169241eab95b76ab52a80c68" + ], + "context": { + "server": { + "runtime": { + "version": "ruby 2.6.5p114 (2019-10-01 revision 67812) [x86_64-darwin18]", + "name": "ruby" + }, + "os": { + "kernel_version": "Darwin Seans-MBP.fritz.box 19.0.0 Darwin Kernel Version 19.0.0: Thu Oct 17 16:17:15 PDT 2019; root:xnu-6153.41.3~29/RELEASE_X86_64 x86_64", + "version": "Darwin Kernel Version 19.0.0: Thu Oct 17 16:17:15 PDT 2019; root:xnu-6153.41.3~29/RELEASE_X86_64", + "build": "19.0.0", + "name": "Darwin" + } + } + }, + "release": { + "dateReleased": null, + "commitCount": 0, + "url": null, + "data": {}, + "lastDeploy": null, + "deployCount": 0, + "dateCreated": "2019-12-08T21:47:47Z", + "lastEvent": "2019-12-09T21:52:05Z", + "version": "b56ae26", + "firstEvent": "2019-12-08T21:47:47Z", + "lastCommit": null, + "shortVersion": "b56ae26", + "authors": [], + "owner": null, + "newGroups": 26, + "ref": null, + "projects": [ + { + "slug": "gitlab-03", + "name": "gitlab-03" + } + ] + }, + "groupID": "1378364652" +} diff --git a/spec/fixtures/sentry/issue_link_sample_response.json b/spec/fixtures/sentry/issue_link_sample_response.json new file mode 100644 index 0000000000000000000000000000000000000000..f7f3220e83d15d5d69efbcbcdc0c8b1f83d775ad --- /dev/null +++ b/spec/fixtures/sentry/issue_link_sample_response.json @@ -0,0 +1,7 @@ +{ + "url": "https://gitlab.com/test/tanuki-inc/issues/3", + "integrationId": 44444, + "displayName": "test/tanuki-inc#3", + "id": 140319, + "key": "gitlab.com/test:test/tanuki-inc#3" +} diff --git a/spec/fixtures/sentry/issue_sample_response.json b/spec/fixtures/sentry/issue_sample_response.json new file mode 100644 index 0000000000000000000000000000000000000000..a320a21de345d3334fcea3f295411f594e472389 --- /dev/null +++ b/spec/fixtures/sentry/issue_sample_response.json @@ -0,0 +1,311 @@ +{ + "activity": [ + { + "data": {}, + "dateCreated": "2018-11-06T21:19:55Z", + "id": "0", + "type": "first_seen", + "user": null + } + ], + "annotations": [], + "assignedTo": null, + "count": "1", + "culprit": "raven.scripts.runner in main", + "firstRelease": { + "authors": [], + "commitCount": 0, + "data": {}, + "dateCreated": "2018-11-06T21:19:55.146Z", + "dateReleased": null, + "deployCount": 0, + "firstEvent": "2018-11-06T21:19:55.271Z", + "lastCommit": null, + "lastDeploy": null, + "lastEvent": "2018-11-06T21:19:55.271Z", + "newGroups": 0, + "owner": null, + "projects": [ + { + "name": "Pump Station", + "slug": "pump-station" + } + ], + "ref": null, + "shortVersion": "1764232", + "url": null, + "version": "17642328ead24b51867165985996d04b29310337" + }, + "firstSeen": "2018-11-06T21:19:55Z", + "hasSeen": false, + "id": "503504", + "isBookmarked": false, + "isPublic": false, + "isSubscribed": true, + "lastRelease": null, + "lastSeen": "2018-11-06T21:19:55Z", + "level": "error", + "logger": null, + "metadata": { + "title": "This is an example Python exception" + }, + "numComments": 0, + "participants": [], + "permalink": "https://sentrytest.gitlab.com/sentry-org/sentry-project/issues/503504/", + "pluginActions": [], + "pluginContexts": [], + "pluginIssues": [ + { + "id": "gitlab", + "issue": { + "url": "https://gitlab.com/gitlab-org/gitlab/issues/1" + } + } + ], + "project": { + "id": "2", + "name": "Pump Station", + "slug": "pump-station" + }, + "seenBy": [], + "shareId": null, + "shortId": "PUMP-STATION-1", + "stats": { + "24h": [ + [ + 1541451600.0, + 557 + ], + [ + 1541455200.0, + 473 + ], + [ + 1541458800.0, + 914 + ], + [ + 1541462400.0, + 991 + ], + [ + 1541466000.0, + 925 + ], + [ + 1541469600.0, + 881 + ], + [ + 1541473200.0, + 182 + ], + [ + 1541476800.0, + 490 + ], + [ + 1541480400.0, + 820 + ], + [ + 1541484000.0, + 322 + ], + [ + 1541487600.0, + 836 + ], + [ + 1541491200.0, + 565 + ], + [ + 1541494800.0, + 758 + ], + [ + 1541498400.0, + 880 + ], + [ + 1541502000.0, + 677 + ], + [ + 1541505600.0, + 381 + ], + [ + 1541509200.0, + 814 + ], + [ + 1541512800.0, + 329 + ], + [ + 1541516400.0, + 446 + ], + [ + 1541520000.0, + 731 + ], + [ + 1541523600.0, + 111 + ], + [ + 1541527200.0, + 926 + ], + [ + 1541530800.0, + 772 + ], + [ + 1541534400.0, + 400 + ], + [ + 1541538000.0, + 943 + ] + ], + "30d": [ + [ + 1538870400.0, + 565 + ], + [ + 1538956800.0, + 12862 + ], + [ + 1539043200.0, + 15617 + ], + [ + 1539129600.0, + 10809 + ], + [ + 1539216000.0, + 15065 + ], + [ + 1539302400.0, + 12927 + ], + [ + 1539388800.0, + 12994 + ], + [ + 1539475200.0, + 13139 + ], + [ + 1539561600.0, + 11838 + ], + [ + 1539648000.0, + 12088 + ], + [ + 1539734400.0, + 12338 + ], + [ + 1539820800.0, + 12768 + ], + [ + 1539907200.0, + 12816 + ], + [ + 1539993600.0, + 15356 + ], + [ + 1540080000.0, + 10910 + ], + [ + 1540166400.0, + 12306 + ], + [ + 1540252800.0, + 12912 + ], + [ + 1540339200.0, + 14700 + ], + [ + 1540425600.0, + 11890 + ], + [ + 1540512000.0, + 11684 + ], + [ + 1540598400.0, + 13510 + ], + [ + 1540684800.0, + 12625 + ], + [ + 1540771200.0, + 12811 + ], + [ + 1540857600.0, + 13180 + ], + [ + 1540944000.0, + 14651 + ], + [ + 1541030400.0, + 14161 + ], + [ + 1541116800.0, + 12612 + ], + [ + 1541203200.0, + 14316 + ], + [ + 1541289600.0, + 14742 + ], + [ + 1541376000.0, + 12505 + ], + [ + 1541462400.0, + 14180 + ] + ] + }, + "status": "unresolved", + "statusDetails": {}, + "subscriptionDetails": null, + "tags": [], + "title": "This is an example Python exception", + "type": "default", + "userCount": 0, + "userReportCount": 0 +} diff --git a/spec/fixtures/sentry/repos_sample_response.json b/spec/fixtures/sentry/repos_sample_response.json new file mode 100644 index 0000000000000000000000000000000000000000..fe389035fe32764fbe32a9554dbe5df43cf338d3 --- /dev/null +++ b/spec/fixtures/sentry/repos_sample_response.json @@ -0,0 +1,15 @@ +[ + { + "status": "active", + "integrationId": "48066", + "externalSlug": 139, + "name": "test / tanuki-inc", + "provider": { + "id": "integrations:gitlab", + "name": "Gitlab" + }, + "url": "https://gitlab.com/test/tanuki-inc", + "id": "52480", + "dateCreated": "2020-01-08T21:15:17.181520Z" + } +] diff --git a/spec/frontend/__mocks__/@gitlab/ui.js b/spec/frontend/__mocks__/@gitlab/ui.js new file mode 100644 index 0000000000000000000000000000000000000000..ef97cb11424103e8fbc8452493bae0a073371efd --- /dev/null +++ b/spec/frontend/__mocks__/@gitlab/ui.js @@ -0,0 +1,19 @@ +export * from '@gitlab/ui'; + +/** + * The @gitlab/ui tooltip directive requires awkward and distracting set up in tests + * for components that use it (e.g., `attachToDocument: true` and `sync: true` passed + * to the `mount` helper from `vue-test-utils`). + * + * This mock decouples those tests from the implementation, removing the need to set + * them up specially just for these tooltips. + */ +export const GlTooltipDirective = { + bind() {}, +}; + +export const GlTooltip = { + render(h) { + return h('div', this.$attrs, this.$slots.default); + }, +}; diff --git a/spec/frontend/admin/statistics_panel/components/app_spec.js b/spec/frontend/admin/statistics_panel/components/app_spec.js index eba61949f8e1d5fd97a1df790c5cb9a7ba24b6eb..dda0c2b857c726f173c2605318cb95c0137df9db 100644 --- a/spec/frontend/admin/statistics_panel/components/app_spec.js +++ b/spec/frontend/admin/statistics_panel/components/app_spec.js @@ -21,7 +21,6 @@ describe('Admin statistics app', () => { wrapper = shallowMount(StatisticsPanelApp, { localVue, store, - sync: false, }); }; diff --git a/spec/frontend/api_spec.js b/spec/frontend/api_spec.js index cef50bf553caa781afd91d8c6a04a1cf6e0292d6..c0126b2330d8de1426d0388f0df58279894e63cd 100644 --- a/spec/frontend/api_spec.js +++ b/spec/frontend/api_spec.js @@ -151,6 +151,21 @@ describe('Api', () => { }); }); + describe('updateProject', () => { + it('update a project with the given payload', done => { + const projectPath = 'foo'; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectPath}`; + mock.onPut(expectedUrl).reply(200, { foo: 'bar' }); + + Api.updateProject(projectPath, { foo: 'bar' }) + .then(({ data }) => { + expect(data.foo).toBe('bar'); + done(); + }) + .catch(done.fail); + }); + }); + describe('projectUsers', () => { it('fetches all users of a particular project', done => { const query = 'dummy query'; diff --git a/spec/frontend/behaviors/bind_in_out_spec.js b/spec/frontend/behaviors/bind_in_out_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..923b6d372dd72460c6963f962f69353e0d68fc07 --- /dev/null +++ b/spec/frontend/behaviors/bind_in_out_spec.js @@ -0,0 +1,204 @@ +import BindInOut from '~/behaviors/bind_in_out'; +import ClassSpecHelper from '../helpers/class_spec_helper'; + +describe('BindInOut', () => { + let testContext; + + beforeEach(() => { + testContext = {}; + }); + + describe('constructor', () => { + beforeEach(() => { + testContext.in = {}; + testContext.out = {}; + + testContext.bindInOut = new BindInOut(testContext.in, testContext.out); + }); + + it('should set .in', () => { + expect(testContext.bindInOut.in).toBe(testContext.in); + }); + + it('should set .out', () => { + expect(testContext.bindInOut.out).toBe(testContext.out); + }); + + it('should set .eventWrapper', () => { + expect(testContext.bindInOut.eventWrapper).toEqual({}); + }); + + describe('if .in is an input', () => { + beforeEach(() => { + testContext.bindInOut = new BindInOut({ tagName: 'INPUT' }); + }); + + it('should set .eventType to keyup ', () => { + expect(testContext.bindInOut.eventType).toEqual('keyup'); + }); + }); + + describe('if .in is a textarea', () => { + beforeEach(() => { + testContext.bindInOut = new BindInOut({ tagName: 'TEXTAREA' }); + }); + + it('should set .eventType to keyup ', () => { + expect(testContext.bindInOut.eventType).toEqual('keyup'); + }); + }); + + describe('if .in is not an input or textarea', () => { + beforeEach(() => { + testContext.bindInOut = new BindInOut({ tagName: 'SELECT' }); + }); + + it('should set .eventType to change ', () => { + expect(testContext.bindInOut.eventType).toEqual('change'); + }); + }); + }); + + describe('addEvents', () => { + beforeEach(() => { + testContext.in = { + addEventListener: jest.fn(), + }; + + testContext.bindInOut = new BindInOut(testContext.in); + + testContext.addEvents = testContext.bindInOut.addEvents(); + }); + + it('should set .eventWrapper.updateOut', () => { + expect(testContext.bindInOut.eventWrapper.updateOut).toEqual(expect.any(Function)); + }); + + it('should call .addEventListener', () => { + expect(testContext.in.addEventListener).toHaveBeenCalledWith( + testContext.bindInOut.eventType, + testContext.bindInOut.eventWrapper.updateOut, + ); + }); + + it('should return the instance', () => { + expect(testContext.addEvents).toBe(testContext.bindInOut); + }); + }); + + describe('updateOut', () => { + beforeEach(() => { + testContext.in = { value: 'the-value' }; + testContext.out = { textContent: 'not-the-value' }; + + testContext.bindInOut = new BindInOut(testContext.in, testContext.out); + + testContext.updateOut = testContext.bindInOut.updateOut(); + }); + + it('should set .out.textContent to .in.value', () => { + expect(testContext.out.textContent).toBe(testContext.in.value); + }); + + it('should return the instance', () => { + expect(testContext.updateOut).toBe(testContext.bindInOut); + }); + }); + + describe('removeEvents', () => { + beforeEach(() => { + testContext.in = { + removeEventListener: jest.fn(), + }; + testContext.updateOut = () => {}; + + testContext.bindInOut = new BindInOut(testContext.in); + testContext.bindInOut.eventWrapper.updateOut = testContext.updateOut; + + testContext.removeEvents = testContext.bindInOut.removeEvents(); + }); + + it('should call .removeEventListener', () => { + expect(testContext.in.removeEventListener).toHaveBeenCalledWith( + testContext.bindInOut.eventType, + testContext.updateOut, + ); + }); + + it('should return the instance', () => { + expect(testContext.removeEvents).toBe(testContext.bindInOut); + }); + }); + + describe('initAll', () => { + beforeEach(() => { + testContext.ins = [0, 1, 2]; + testContext.instances = []; + + jest.spyOn(document, 'querySelectorAll').mockReturnValue(testContext.ins); + jest.spyOn(Array.prototype, 'map'); + jest.spyOn(BindInOut, 'init').mockImplementation(() => {}); + + testContext.initAll = BindInOut.initAll(); + }); + + ClassSpecHelper.itShouldBeAStaticMethod(BindInOut, 'initAll'); + + it('should call .querySelectorAll', () => { + expect(document.querySelectorAll).toHaveBeenCalledWith('*[data-bind-in]'); + }); + + it('should call .map', () => { + expect(Array.prototype.map).toHaveBeenCalledWith(expect.any(Function)); + }); + + it('should call .init for each element', () => { + expect(BindInOut.init.mock.calls.length).toEqual(3); + }); + + it('should return an array of instances', () => { + expect(testContext.initAll).toEqual(expect.any(Array)); + }); + }); + + describe('init', () => { + beforeEach(() => { + // eslint-disable-next-line func-names + jest.spyOn(BindInOut.prototype, 'addEvents').mockImplementation(function() { + return this; + }); + // eslint-disable-next-line func-names + jest.spyOn(BindInOut.prototype, 'updateOut').mockImplementation(function() { + return this; + }); + + testContext.init = BindInOut.init({}, {}); + }); + + ClassSpecHelper.itShouldBeAStaticMethod(BindInOut, 'init'); + + it('should call .addEvents', () => { + expect(BindInOut.prototype.addEvents).toHaveBeenCalled(); + }); + + it('should call .updateOut', () => { + expect(BindInOut.prototype.updateOut).toHaveBeenCalled(); + }); + + describe('if no anOut is provided', () => { + beforeEach(() => { + testContext.anIn = { dataset: { bindIn: 'the-data-bind-in' } }; + + jest.spyOn(document, 'querySelector').mockImplementation(() => {}); + + BindInOut.init(testContext.anIn); + }); + + it('should call .querySelector', () => { + expect(document.querySelector).toHaveBeenCalledWith( + `*[data-bind-out="${testContext.anIn.dataset.bindIn}"]`, + ); + }); + }); + }); +}); diff --git a/spec/frontend/behaviors/markdown/paste_markdown_table_spec.js b/spec/frontend/behaviors/markdown/paste_markdown_table_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..a98919e21135357fa96002399fa92a4fa613b84e --- /dev/null +++ b/spec/frontend/behaviors/markdown/paste_markdown_table_spec.js @@ -0,0 +1,113 @@ +import PasteMarkdownTable from '~/behaviors/markdown/paste_markdown_table'; + +describe('PasteMarkdownTable', () => { + let data; + + beforeEach(() => { + const event = new window.Event('paste'); + + Object.defineProperty(event, 'dataTransfer', { + value: { + getData: jest.fn().mockImplementation(type => { + if (type === 'text/html') { + return '<table><tr><td>First</td><td>Second</td></tr></table>'; + } + return 'First\tSecond'; + }), + }, + }); + + data = event.dataTransfer; + }); + + describe('isTable', () => { + it('return false when no HTML data is provided', () => { + data.types = ['text/plain']; + + expect(new PasteMarkdownTable(data).isTable()).toBe(false); + }); + + it('returns false when no text data is provided', () => { + data.types = ['text/html']; + + expect(new PasteMarkdownTable(data).isTable()).toBe(false); + }); + + it('returns true when a table is provided in both text and HTML', () => { + data.types = ['text/html', 'text/plain']; + + expect(new PasteMarkdownTable(data).isTable()).toBe(true); + }); + + it('returns false when no HTML table is included', () => { + data.types = ['text/html', 'text/plain']; + data.getData = jest.fn().mockImplementation(() => 'nothing'); + + expect(new PasteMarkdownTable(data).isTable()).toBe(false); + }); + + it('returns false when the number of rows are not consistent', () => { + data.types = ['text/html', 'text/plain']; + data.getData = jest.fn().mockImplementation(mimeType => { + if (mimeType === 'text/html') { + return '<table><tr><td>def test<td></tr></table>'; + } + return "def test\n 'hello'\n"; + }); + + expect(new PasteMarkdownTable(data).isTable()).toBe(false); + }); + }); + + describe('convertToTableMarkdown', () => { + it('returns a Markdown table', () => { + data.types = ['text/html', 'text/plain']; + data.getData = jest.fn().mockImplementation(type => { + if (type === 'text/html') { + return '<table><tr><td>First</td><td>Last</td><tr><td>John</td><td>Doe</td><tr><td>Jane</td><td>Doe</td></table>'; + } else if (type === 'text/plain') { + return 'First\tLast\nJohn\tDoe\nJane\tDoe'; + } + + return ''; + }); + + const expected = [ + '| First | Last |', + '|-------|------|', + '| John | Doe |', + '| Jane | Doe |', + ].join('\n'); + + const converter = new PasteMarkdownTable(data); + + expect(converter.isTable()).toBe(true); + expect(converter.convertToTableMarkdown()).toBe(expected); + }); + + it('returns a Markdown table with rows normalized', () => { + data.types = ['text/html', 'text/plain']; + data.getData = jest.fn().mockImplementation(type => { + if (type === 'text/html') { + return '<table><tr><td>First</td><td>Last</td><tr><td>John</td><td>Doe</td><tr><td>Jane</td><td>/td></table>'; + } else if (type === 'text/plain') { + return 'First\tLast\nJohn\tDoe\nJane'; + } + + return ''; + }); + + const expected = [ + '| First | Last |', + '|-------|------|', + '| John | Doe |', + '| Jane | |', + ].join('\n'); + + const converter = new PasteMarkdownTable(data); + + expect(converter.isTable()).toBe(true); + expect(converter.convertToTableMarkdown()).toBe(expected); + }); + }); +}); diff --git a/spec/frontend/boards/components/issue_time_estimate_spec.js b/spec/frontend/boards/components/issue_time_estimate_spec.js index 25e922931c359d859f3bbc0d4bb6bebd0edff7f3..162a6df828bd7001e9d56e89bcc1de6594587f3c 100644 --- a/spec/frontend/boards/components/issue_time_estimate_spec.js +++ b/spec/frontend/boards/components/issue_time_estimate_spec.js @@ -20,7 +20,6 @@ describe('Issue Time Estimate component', () => { propsData: { estimate: 374460, }, - sync: false, }); }); @@ -61,7 +60,6 @@ describe('Issue Time Estimate component', () => { propsData: { estimate: 374460, }, - sync: false, }); }); diff --git a/spec/frontend/boards/issue_card_spec.js b/spec/frontend/boards/issue_card_spec.js index c7ab477c0af2f00ab98fd66318d908aea12bb2bf..df55a106945165f8e93a3df68c5260263e55752c 100644 --- a/spec/frontend/boards/issue_card_spec.js +++ b/spec/frontend/boards/issue_card_spec.js @@ -50,8 +50,6 @@ describe('Issue card component', () => { rootPath: '/', }, store, - sync: false, - attachToDocument: true, }); }); @@ -267,17 +265,13 @@ describe('Issue card component', () => { }); it('renders label', () => { - const nodes = wrapper - .findAll('.badge') - .wrappers.map(label => label.attributes('data-original-title')); + const nodes = wrapper.findAll('.badge').wrappers.map(label => label.attributes('title')); expect(nodes.includes(label1.description)).toBe(true); }); it('sets label description as title', () => { - expect(wrapper.find('.badge').attributes('data-original-title')).toContain( - label1.description, - ); + expect(wrapper.find('.badge').attributes('title')).toContain(label1.description); }); it('sets background color of button', () => { diff --git a/spec/javascripts/bootstrap_jquery_spec.js b/spec/frontend/bootstrap_jquery_spec.js similarity index 52% rename from spec/javascripts/bootstrap_jquery_spec.js rename to spec/frontend/bootstrap_jquery_spec.js index 6957cf40301973c11fbea97da13cc51d50ad3fff..d5d592e38392ea657ee728aa3bd626b317cf4150 100644 --- a/spec/javascripts/bootstrap_jquery_spec.js +++ b/spec/frontend/bootstrap_jquery_spec.js @@ -1,37 +1,40 @@ import $ from 'jquery'; import '~/commons/bootstrap'; -describe('Bootstrap jQuery extensions', function() { - describe('disable', function() { - beforeEach(function() { - return setFixtures('<input type="text" />'); +describe('Bootstrap jQuery extensions', () => { + describe('disable', () => { + beforeEach(() => { + setFixtures('<input type="text" />'); }); - it('adds the disabled attribute', function() { + it('adds the disabled attribute', () => { const $input = $('input').first(); $input.disable(); expect($input).toHaveAttr('disabled', 'disabled'); }); - return it('adds the disabled class', function() { + + it('adds the disabled class', () => { const $input = $('input').first(); $input.disable(); expect($input).toHaveClass('disabled'); }); }); - return describe('enable', function() { - beforeEach(function() { - return setFixtures('<input type="text" disabled="disabled" class="disabled" />'); + + describe('enable', () => { + beforeEach(() => { + setFixtures('<input type="text" disabled="disabled" class="disabled" />'); }); - it('removes the disabled attribute', function() { + it('removes the disabled attribute', () => { const $input = $('input').first(); $input.enable(); expect($input).not.toHaveAttr('disabled'); }); - return it('removes the disabled class', function() { + + it('removes the disabled class', () => { const $input = $('input').first(); $input.enable(); diff --git a/spec/javascripts/branches/branches_delete_modal_spec.js b/spec/frontend/branches/branches_delete_modal_spec.js similarity index 91% rename from spec/javascripts/branches/branches_delete_modal_spec.js rename to spec/frontend/branches/branches_delete_modal_spec.js index b223b8e2c0aaec884c04d9dc20a281ba72508983..21608feafc8b9e3cb70978bf352e0e8f05a0dcdb 100644 --- a/spec/javascripts/branches/branches_delete_modal_spec.js +++ b/spec/frontend/branches/branches_delete_modal_spec.js @@ -15,7 +15,7 @@ describe('branches delete modal', () => { </div> `); $deleteButton = $('.js-delete-branch'); - submitSpy = jasmine.createSpy('submit').and.callFake(event => event.preventDefault()); + submitSpy = jest.fn(event => event.preventDefault()); $('#modal-delete-branch form').on('submit', submitSpy); // eslint-disable-next-line no-new new DeleteModal(); diff --git a/spec/frontend/branches/components/__snapshots__/divergence_graph_spec.js.snap b/spec/frontend/branches/components/__snapshots__/divergence_graph_spec.js.snap index 511c027dbc23ef47874acc5368a2337440c004a6..c9948db95f816ce2343a839df0da1d60e0a6f2e1 100644 --- a/spec/frontend/branches/components/__snapshots__/divergence_graph_spec.js.snap +++ b/spec/frontend/branches/components/__snapshots__/divergence_graph_spec.js.snap @@ -5,7 +5,7 @@ exports[`Branch divergence graph component renders ahead and behind count 1`] = class="divergence-graph px-2 d-none d-md-block" title="10 commits behind master, 10 commits ahead" > - <graphbar-stub + <graph-bar-stub count="10" maxcommits="100" position="left" @@ -15,7 +15,7 @@ exports[`Branch divergence graph component renders ahead and behind count 1`] = class="graph-separator pull-left mt-1" /> - <graphbar-stub + <graph-bar-stub count="10" maxcommits="100" position="right" @@ -28,7 +28,7 @@ exports[`Branch divergence graph component renders distance count 1`] = ` class="divergence-graph px-2 d-none d-md-block" title="More than 900 commits different with master" > - <graphbar-stub + <graph-bar-stub count="900" maxcommits="100" position="full" diff --git a/spec/frontend/clusters/components/applications_spec.js b/spec/frontend/clusters/components/applications_spec.js index 2d7958a6b657edddb08d46a9fdac8654d95b44a3..01e9b04dcd759f6b4b5a2484abc07a5039b2c2f7 100644 --- a/spec/frontend/clusters/components/applications_spec.js +++ b/spec/frontend/clusters/components/applications_spec.js @@ -17,7 +17,6 @@ describe('Applications', () => { gon.features = gon.features || {}; gon.features.enableClusterApplicationElasticStack = true; - gon.features.enableClusterApplicationCrossplane = true; }); afterEach(() => { @@ -190,6 +189,7 @@ describe('Applications', () => { title: 'Ingress', status: 'installed', externalHostname: 'localhost.localdomain', + modsecurity_enabled: false, }, helm: { title: 'Helm Tiller' }, cert_manager: { title: 'Cert-Manager' }, @@ -198,7 +198,7 @@ describe('Applications', () => { prometheus: { title: 'Prometheus' }, jupyter: { title: 'JupyterHub', hostname: '' }, knative: { title: 'Knative', hostname: '' }, - elastic_stack: { title: 'Elastic Stack', kibana_hostname: '' }, + elastic_stack: { title: 'Elastic Stack' }, }, }); @@ -432,74 +432,33 @@ describe('Applications', () => { }); describe('Elastic Stack application', () => { - describe('with ingress installed with ip & elastic stack installable', () => { + describe('with elastic stack installable', () => { it('renders hostname active input', () => { vm = mountComponent(Applications, { applications: { ...APPLICATIONS_MOCK_STATE, - ingress: { - title: 'Ingress', - status: 'installed', - externalIp: '1.1.1.1', - }, }, }); expect( vm.$el - .querySelector('.js-cluster-application-row-elastic_stack .js-hostname') - .getAttribute('readonly'), - ).toEqual(null); - }); - }); - - describe('with ingress installed without external ip', () => { - it('does not render hostname input', () => { - vm = mountComponent(Applications, { - applications: { - ...APPLICATIONS_MOCK_STATE, - ingress: { title: 'Ingress', status: 'installed' }, - }, - }); - - expect(vm.$el.querySelector('.js-cluster-application-row-elastic_stack .js-hostname')).toBe( - null, - ); + .querySelector( + '.js-cluster-application-row-elastic_stack .js-cluster-application-install-button', + ) + .getAttribute('disabled'), + ).toEqual('disabled'); }); }); - describe('with ingress & elastic stack installed', () => { - it('renders readonly input', () => { + describe('elastic stack installed', () => { + it('renders uninstall button', () => { vm = mountComponent(Applications, { applications: { ...APPLICATIONS_MOCK_STATE, - ingress: { title: 'Ingress', status: 'installed', externalIp: '1.1.1.1' }, - elastic_stack: { title: 'Elastic Stack', status: 'installed', kibana_hostname: '' }, + elastic_stack: { title: 'Elastic Stack', status: 'installed' }, }, }); - expect( - vm.$el - .querySelector('.js-cluster-application-row-elastic_stack .js-hostname') - .getAttribute('readonly'), - ).toEqual('readonly'); - }); - }); - - describe('without ingress installed', () => { - beforeEach(() => { - vm = mountComponent(Applications, { - applications: APPLICATIONS_MOCK_STATE, - }); - }); - - it('does not render input', () => { - expect(vm.$el.querySelector('.js-cluster-application-row-elastic_stack .js-hostname')).toBe( - null, - ); - }); - - it('renders disabled install button', () => { expect( vm.$el .querySelector( diff --git a/spec/frontend/clusters/components/knative_domain_editor_spec.js b/spec/frontend/clusters/components/knative_domain_editor_spec.js index 242b5701f8b76aaa331c710c9fa406f6bcfdae64..6514d883c0d22fd0e32537223ffcf9f3324e61a9 100644 --- a/spec/frontend/clusters/components/knative_domain_editor_spec.js +++ b/spec/frontend/clusters/components/knative_domain_editor_spec.js @@ -25,6 +25,7 @@ describe('KnativeDomainEditor', () => { afterEach(() => { wrapper.destroy(); + wrapper = null; }); describe('knative has an assigned IP address', () => { @@ -78,7 +79,9 @@ describe('KnativeDomainEditor', () => { it('triggers save event and pass current knative hostname', () => { wrapper.find(LoadingButton).vm.$emit('click'); - expect(wrapper.emitted('save')[0]).toEqual([knative.hostname]); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.emitted('save')[0]).toEqual([knative.hostname]); + }); }); }); @@ -101,11 +104,15 @@ describe('KnativeDomainEditor', () => { describe('when knative domain name input changes', () => { it('emits "set" event with updated domain name', () => { + createComponent({ knative }); + const newHostname = 'newhostname.com'; wrapper.setData({ knativeHostname: newHostname }); - expect(wrapper.emitted('set')[0]).toEqual([newHostname]); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.emitted('set')[0]).toEqual([newHostname]); + }); }); }); @@ -117,7 +124,9 @@ describe('KnativeDomainEditor', () => { it('displays an error banner indicating the operation failure', () => { wrapper.setProps({ knative: { updateFailed: true, ...knative } }); - expect(wrapper.find('.js-cluster-knative-domain-name-failure-message').exists()).toBe(true); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.find('.js-cluster-knative-domain-name-failure-message').exists()).toBe(true); + }); }); }); diff --git a/spec/frontend/clusters/components/remove_cluster_confirmation_spec.js b/spec/frontend/clusters/components/remove_cluster_confirmation_spec.js index b5aead238adca469b3d3cf01ba93c718503b963a..091d4e0798784d9c4d4b37093644e643503cead3 100644 --- a/spec/frontend/clusters/components/remove_cluster_confirmation_spec.js +++ b/spec/frontend/clusters/components/remove_cluster_confirmation_spec.js @@ -13,7 +13,6 @@ describe('Remove cluster confirmation modal', () => { clusterName: 'clusterName', ...props, }, - sync: false, }); }; diff --git a/spec/frontend/clusters/components/uninstall_application_confirmation_modal_spec.js b/spec/frontend/clusters/components/uninstall_application_confirmation_modal_spec.js index f95bce775c6d6f432782a41d6457126a92b57d90..c07f685182618ba3d8541e958b722774f4d0111c 100644 --- a/spec/frontend/clusters/components/uninstall_application_confirmation_modal_spec.js +++ b/spec/frontend/clusters/components/uninstall_application_confirmation_modal_spec.js @@ -35,9 +35,10 @@ describe('UninstallApplicationConfirmationModal', () => { wrapper.find(GlModal).vm.$emit('ok'); }); - it('emits confirm event', () => { - expect(wrapper.emitted('confirm')).toBeTruthy(); - }); + it('emits confirm event', () => + wrapper.vm.$nextTick().then(() => { + expect(wrapper.emitted('confirm')).toBeTruthy(); + })); it('calls track uninstall button click mixin', () => { expect(wrapper.vm.trackUninstallButtonClick).toHaveBeenCalledWith(INGRESS); diff --git a/spec/frontend/clusters/services/crossplane_provider_stack_spec.js b/spec/frontend/clusters/services/crossplane_provider_stack_spec.js index d43dc9333b4cd6ed05f1e3d098f83030e0130b8e..3e5f8de8e7b89b596badfb386c091d4f03f4633f 100644 --- a/spec/frontend/clusters/services/crossplane_provider_stack_spec.js +++ b/spec/frontend/clusters/services/crossplane_provider_stack_spec.js @@ -70,7 +70,9 @@ describe('CrossplaneProviderStack component', () => { }; createComponent({ crossplane }); findFirstDropdownElement().vm.$emit('click'); - expect(wrapper.emitted().set[0][0].code).toEqual('gcp'); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.emitted().set[0][0].code).toEqual('gcp'); + }); }); it('renders the correct dropdown text when no stack is selected', () => { diff --git a/spec/frontend/clusters/services/mock_data.js b/spec/frontend/clusters/services/mock_data.js index 016f5a259b5c6f7ebfae6117b4a6da9a59a99c5b..f0bcf5d980fbb589ab98022fbdb9142cb6939416 100644 --- a/spec/frontend/clusters/services/mock_data.js +++ b/spec/frontend/clusters/services/mock_data.js @@ -150,14 +150,14 @@ const DEFAULT_APPLICATION_STATE = { const APPLICATIONS_MOCK_STATE = { helm: { title: 'Helm Tiller', status: 'installable' }, - ingress: { title: 'Ingress', status: 'installable' }, + ingress: { title: 'Ingress', status: 'installable', modsecurity_enabled: false }, crossplane: { title: 'Crossplane', status: 'installable', stack: '' }, cert_manager: { title: 'Cert-Manager', status: 'installable' }, runner: { title: 'GitLab Runner' }, prometheus: { title: 'Prometheus' }, jupyter: { title: 'JupyterHub', status: 'installable', hostname: '' }, knative: { title: 'Knative ', status: 'installable', hostname: '' }, - elastic_stack: { title: 'Elastic Stack', status: 'installable', kibana_hostname: '' }, + elastic_stack: { title: 'Elastic Stack', status: 'installable' }, }; export { CLUSTERS_MOCK_DATA, DEFAULT_APPLICATION_STATE, APPLICATIONS_MOCK_STATE }; diff --git a/spec/frontend/clusters/stores/clusters_store_spec.js b/spec/frontend/clusters/stores/clusters_store_spec.js index 71d4daceb753b443bb73eea6c6b45a98c979f724..f2dbdd0638b8ff8d0b77ceb30897608ae7bf1d39 100644 --- a/spec/frontend/clusters/stores/clusters_store_spec.js +++ b/spec/frontend/clusters/stores/clusters_store_spec.js @@ -86,6 +86,7 @@ describe('Clusters Store', () => { uninstallSuccessful: false, uninstallFailed: false, validationError: null, + modsecurity_enabled: false, }, runner: { title: 'GitLab Runner', @@ -166,7 +167,6 @@ describe('Clusters Store', () => { installFailed: true, statusReason: mockResponseData.applications[7].status_reason, requestReason: null, - kibana_hostname: '', installed: false, uninstallable: false, uninstallSuccessful: false, @@ -215,16 +215,5 @@ describe('Clusters Store', () => { `jupyter.${store.state.applications.ingress.externalIp}.nip.io`, ); }); - - it('sets default hostname for elastic stack when ingress has a ip address', () => { - const mockResponseData = - CLUSTERS_MOCK_DATA.GET['/gitlab-org/gitlab-shell/clusters/2/status.json'].data; - - store.updateStateFromServer(mockResponseData); - - expect(store.state.applications.elastic_stack.kibana_hostname).toEqual( - `kibana.${store.state.applications.ingress.externalIp}.nip.io`, - ); - }); }); }); diff --git a/spec/frontend/commit/commit_pipeline_status_component_spec.js b/spec/frontend/commit/commit_pipeline_status_component_spec.js index a2a6d405eab30410ea0ef555df7f95b36bece223..9281d1d02a385da9c08c5fae69afd67eaa2553be 100644 --- a/spec/frontend/commit/commit_pipeline_status_component_spec.js +++ b/spec/frontend/commit/commit_pipeline_status_component_spec.js @@ -33,7 +33,6 @@ describe('Commit pipeline status component', () => { ...defaultProps, ...props, }, - sync: false, }); }; diff --git a/spec/frontend/confidential_merge_request/components/__snapshots__/project_form_group_spec.js.snap b/spec/frontend/confidential_merge_request/components/__snapshots__/project_form_group_spec.js.snap index d69a9f90d65c271e1eeb24895d9acd5cf3d803f0..f7b68d96129fabe48e6fbaaa60a39b127ce2bd81 100644 --- a/spec/frontend/confidential_merge_request/components/__snapshots__/project_form_group_spec.js.snap +++ b/spec/frontend/confidential_merge_request/components/__snapshots__/project_form_group_spec.js.snap @@ -18,11 +18,11 @@ exports[`Confidential merge request project form group component renders empty s No forks are available to you. <br /> - <glsprintf-stub + <gl-sprintf-stub message="To protect this issue's confidentiality, %{forkLink} and set the fork's visibility to private." /> - <gllink-stub + <gl-link-stub class="w-auto p-0 d-inline-block text-primary bg-transparent" href="/help" target="_blank" @@ -37,7 +37,7 @@ exports[`Confidential merge request project form group component renders empty s aria-hidden="true" class="fa fa-question-circle" /> - </gllink-stub> + </gl-link-stub> </p> </div> </div> @@ -61,11 +61,11 @@ exports[`Confidential merge request project form group component renders fork dr No forks are available to you. <br /> - <glsprintf-stub + <gl-sprintf-stub message="To protect this issue's confidentiality, %{forkLink} and set the fork's visibility to private." /> - <gllink-stub + <gl-link-stub class="w-auto p-0 d-inline-block text-primary bg-transparent" href="/help" target="_blank" @@ -80,7 +80,7 @@ exports[`Confidential merge request project form group component renders fork dr aria-hidden="true" class="fa fa-question-circle" /> - </gllink-stub> + </gl-link-stub> </p> </div> </div> diff --git a/spec/frontend/confidential_merge_request/components/project_form_group_spec.js b/spec/frontend/confidential_merge_request/components/project_form_group_spec.js index 3001363f7b90a1d723be3f9d756ebd2571fb130e..975701ebd9635851051cfba83eb8f8ccb9c4337f 100644 --- a/spec/frontend/confidential_merge_request/components/project_form_group_spec.js +++ b/spec/frontend/confidential_merge_request/components/project_form_group_spec.js @@ -1,9 +1,8 @@ -import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import axios from '~/lib/utils/axios_utils'; import ProjectFormGroup from '~/confidential_merge_request/components/project_form_group.vue'; -const localVue = createLocalVue(); const mockData = [ { id: 1, @@ -30,7 +29,6 @@ function factory(projects = mockData) { mock.onGet(/api\/(.*)\/projects\/gitlab-org%2Fgitlab-ce\/forks/).reply(200, projects); vm = shallowMount(ProjectFormGroup, { - localVue, propsData: { namespacePath: 'gitlab-org', projectPath: 'gitlab-org/gitlab-ce', @@ -49,7 +47,7 @@ describe('Confidential merge request project form group component', () => { it('renders fork dropdown', () => { factory(); - return localVue.nextTick(() => { + return vm.vm.$nextTick(() => { expect(vm.element).toMatchSnapshot(); }); }); @@ -57,7 +55,7 @@ describe('Confidential merge request project form group component', () => { it('sets selected project as first fork', () => { factory(); - return localVue.nextTick(() => { + return vm.vm.$nextTick(() => { expect(vm.vm.selectedProject).toEqual({ id: 1, name: 'root / gitlab-ce', @@ -70,7 +68,7 @@ describe('Confidential merge request project form group component', () => { it('renders empty state when response is empty', () => { factory([]); - return localVue.nextTick(() => { + return vm.vm.$nextTick(() => { expect(vm.element).toMatchSnapshot(); }); }); diff --git a/spec/frontend/contributors/component/__snapshots__/contributors_spec.js.snap b/spec/frontend/contributors/component/__snapshots__/contributors_spec.js.snap index b87afdd7eb43e3f63f7380e19eb6272d9b832f14..184d0321dc1b5422e5f539fab882ecb2702c1dcf 100644 --- a/spec/frontend/contributors/component/__snapshots__/contributors_spec.js.snap +++ b/spec/frontend/contributors/component/__snapshots__/contributors_spec.js.snap @@ -17,7 +17,11 @@ exports[`Contributors charts should render charts when loading completed and the <glareachart-stub data="[object Object]" height="264" + includelegendavgmax="true" + legendaveragetext="Avg" + legendmaxtext="Max" option="[object Object]" + thresholds="" /> </div> @@ -38,7 +42,11 @@ exports[`Contributors charts should render charts when loading completed and the <glareachart-stub data="[object Object]" height="216" + includelegendavgmax="true" + legendaveragetext="Avg" + legendmaxtext="Max" option="[object Object]" + thresholds="" /> </div> </div> diff --git a/spec/frontend/contributors/component/contributors_spec.js b/spec/frontend/contributors/component/contributors_spec.js index 1d5605ef516a90d65e31695d3e5028ad5c52d834..3e4924ed906293d662300db34d6be332daff300a 100644 --- a/spec/frontend/contributors/component/contributors_spec.js +++ b/spec/frontend/contributors/component/contributors_spec.js @@ -1,11 +1,10 @@ import Vue from 'vue'; -import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import { createStore } from '~/contributors/stores'; import axios from '~/lib/utils/axios_utils'; import ContributorsCharts from '~/contributors/components/contributors.vue'; -const localVue = createLocalVue(); let wrapper; let mock; let store; @@ -52,7 +51,7 @@ describe('Contributors charts', () => { it('should display loader whiled loading data', () => { wrapper.vm.$store.state.loading = true; - return localVue.nextTick(() => { + return wrapper.vm.$nextTick(() => { expect(wrapper.find('.contributors-loader').exists()).toBe(true); }); }); @@ -60,7 +59,7 @@ describe('Contributors charts', () => { it('should render charts when loading completed and there is chart data', () => { wrapper.vm.$store.state.loading = false; wrapper.vm.$store.state.chartData = chartData; - return localVue.nextTick(() => { + return wrapper.vm.$nextTick(() => { expect(wrapper.find('.contributors-loader').exists()).toBe(false); expect(wrapper.find('.contributors-charts').exists()).toBe(true); expect(wrapper.element).toMatchSnapshot(); diff --git a/spec/frontend/create_cluster/eks_cluster/components/cluster_form_dropdown_spec.js b/spec/frontend/create_cluster/components/cluster_form_dropdown_spec.js similarity index 63% rename from spec/frontend/create_cluster/eks_cluster/components/cluster_form_dropdown_spec.js rename to spec/frontend/create_cluster/components/cluster_form_dropdown_spec.js index c9cdd7285093282854c408f5926ffcba7be6f843..292b8694fbc27a3d3b1df3b5f0b0ab2afaccfc44 100644 --- a/spec/frontend/create_cluster/eks_cluster/components/cluster_form_dropdown_spec.js +++ b/spec/frontend/create_cluster/components/cluster_form_dropdown_spec.js @@ -2,7 +2,7 @@ import { shallowMount } from '@vue/test-utils'; import $ from 'jquery'; import { GlIcon } from '@gitlab/ui'; -import ClusterFormDropdown from '~/create_cluster/eks_cluster/components/cluster_form_dropdown.vue'; +import ClusterFormDropdown from '~/create_cluster/components/cluster_form_dropdown.vue'; import DropdownButton from '~/vue_shared/components/dropdown/dropdown_button.vue'; import DropdownSearchInput from '~/vue_shared/components/dropdown/dropdown_search_input.vue'; @@ -20,7 +20,10 @@ describe('ClusterFormDropdown', () => { describe('when initial value is provided', () => { it('sets selectedItem to initial value', () => { vm.setProps({ items, value: secondItem.value }); - expect(vm.find(DropdownButton).props('toggleText')).toEqual(secondItem.name); + + return vm.vm.$nextTick().then(() => { + expect(vm.find(DropdownButton).props('toggleText')).toEqual(secondItem.name); + }); }); }); @@ -30,16 +33,22 @@ describe('ClusterFormDropdown', () => { vm.setProps({ placeholder }); - expect(vm.find(DropdownButton).props('toggleText')).toEqual(placeholder); + return vm.vm.$nextTick().then(() => { + expect(vm.find(DropdownButton).props('toggleText')).toEqual(placeholder); + }); }); }); describe('when an item is selected', () => { beforeEach(() => { vm.setProps({ items }); - vm.findAll('.js-dropdown-item') - .at(1) - .trigger('click'); + + return vm.vm.$nextTick().then(() => { + vm.findAll('.js-dropdown-item') + .at(1) + .trigger('click'); + return vm.vm.$nextTick(); + }); }); it('emits input event with selected item', () => { @@ -52,12 +61,20 @@ describe('ClusterFormDropdown', () => { beforeEach(() => { vm.setProps({ items, multiple: true, value }); - vm.findAll('.js-dropdown-item') - .at(0) - .trigger('click'); - vm.findAll('.js-dropdown-item') - .at(1) - .trigger('click'); + return vm.vm + .$nextTick() + .then(() => { + vm.findAll('.js-dropdown-item') + .at(0) + .trigger('click'); + return vm.vm.$nextTick(); + }) + .then(() => { + vm.findAll('.js-dropdown-item') + .at(1) + .trigger('click'); + return vm.vm.$nextTick(); + }); }); it('emits input event with an array of selected items', () => { @@ -68,6 +85,7 @@ describe('ClusterFormDropdown', () => { describe('when multiple items can be selected', () => { beforeEach(() => { vm.setProps({ items, multiple: true, value: firstItem.value }); + return vm.vm.$nextTick(); }); it('displays a checked GlIcon next to the item', () => { @@ -85,7 +103,9 @@ describe('ClusterFormDropdown', () => { vm.setProps({ labelProperty, items: customLabelItems, value: currentValue }); - expect(vm.find(DropdownButton).props('toggleText')).toEqual(label); + return vm.vm.$nextTick().then(() => { + expect(vm.find(DropdownButton).props('toggleText')).toEqual(label); + }); }); }); @@ -93,7 +113,9 @@ describe('ClusterFormDropdown', () => { it('dropdown button isLoading', () => { vm.setProps({ loading: true }); - expect(vm.find(DropdownButton).props('isLoading')).toBe(true); + return vm.vm.$nextTick().then(() => { + expect(vm.find(DropdownButton).props('isLoading')).toBe(true); + }); }); }); @@ -103,7 +125,9 @@ describe('ClusterFormDropdown', () => { vm.setProps({ loading: true, loadingText }); - expect(vm.find(DropdownButton).props('toggleText')).toEqual(loadingText); + return vm.vm.$nextTick().then(() => { + expect(vm.find(DropdownButton).props('toggleText')).toEqual(loadingText); + }); }); }); @@ -111,7 +135,9 @@ describe('ClusterFormDropdown', () => { it('dropdown button isDisabled', () => { vm.setProps({ disabled: true }); - expect(vm.find(DropdownButton).props('isDisabled')).toBe(true); + return vm.vm.$nextTick().then(() => { + expect(vm.find(DropdownButton).props('isDisabled')).toBe(true); + }); }); }); @@ -121,7 +147,9 @@ describe('ClusterFormDropdown', () => { vm.setProps({ disabled: true, disabledText }); - expect(vm.find(DropdownButton).props('toggleText')).toBe(disabledText); + return vm.vm.$nextTick().then(() => { + expect(vm.find(DropdownButton).props('toggleText')).toBe(disabledText); + }); }); }); @@ -129,7 +157,9 @@ describe('ClusterFormDropdown', () => { it('sets border-danger class selector to dropdown toggle', () => { vm.setProps({ hasErrors: true }); - expect(vm.find(DropdownButton).classes('border-danger')).toBe(true); + return vm.vm.$nextTick().then(() => { + expect(vm.find(DropdownButton).classes('border-danger')).toBe(true); + }); }); }); @@ -139,7 +169,9 @@ describe('ClusterFormDropdown', () => { vm.setProps({ hasErrors: true, errorMessage }); - expect(vm.find('.js-eks-dropdown-error-message').text()).toEqual(errorMessage); + return vm.vm.$nextTick().then(() => { + expect(vm.find('.js-eks-dropdown-error-message').text()).toEqual(errorMessage); + }); }); }); @@ -149,7 +181,9 @@ describe('ClusterFormDropdown', () => { vm.setProps({ items: [], emptyText }); - expect(vm.find('.js-empty-text').text()).toEqual(emptyText); + return vm.vm.$nextTick().then(() => { + expect(vm.find('.js-empty-text').text()).toEqual(emptyText); + }); }); }); @@ -158,7 +192,9 @@ describe('ClusterFormDropdown', () => { vm.setProps({ searchFieldPlaceholder }); - expect(vm.find(DropdownSearchInput).props('placeholderText')).toEqual(searchFieldPlaceholder); + return vm.vm.$nextTick().then(() => { + expect(vm.find(DropdownSearchInput).props('placeholderText')).toEqual(searchFieldPlaceholder); + }); }); it('it filters results by search query', () => { @@ -167,8 +203,10 @@ describe('ClusterFormDropdown', () => { vm.setProps({ items }); vm.setData({ searchQuery }); - expect(vm.findAll('.js-dropdown-item').length).toEqual(1); - expect(vm.find('.js-dropdown-item').text()).toEqual(secondItem.name); + return vm.vm.$nextTick().then(() => { + expect(vm.findAll('.js-dropdown-item').length).toEqual(1); + expect(vm.find('.js-dropdown-item').text()).toEqual(secondItem.name); + }); }); it('focuses dropdown search input when dropdown is displayed', () => { @@ -178,6 +216,8 @@ describe('ClusterFormDropdown', () => { $(dropdownEl).trigger('shown.bs.dropdown'); - expect(vm.find(DropdownSearchInput).props('focused')).toBe(true); + return vm.vm.$nextTick(() => { + expect(vm.find(DropdownSearchInput).props('focused')).toBe(true); + }); }); }); diff --git a/spec/frontend/create_cluster/eks_cluster/components/eks_cluster_configuration_form_spec.js b/spec/frontend/create_cluster/eks_cluster/components/eks_cluster_configuration_form_spec.js index d3992c6751cecd6ccf076d3b23984384ed02ef05..25034dcf5add6584401f56ecaf3d61f8a3e6bb0c 100644 --- a/spec/frontend/create_cluster/eks_cluster/components/eks_cluster_configuration_form_spec.js +++ b/spec/frontend/create_cluster/eks_cluster/components/eks_cluster_configuration_form_spec.js @@ -5,7 +5,7 @@ import { GlFormCheckbox } from '@gitlab/ui'; import EksClusterConfigurationForm from '~/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue'; import eksClusterFormState from '~/create_cluster/eks_cluster/store/state'; -import clusterDropdownStoreState from '~/create_cluster/eks_cluster/store/cluster_dropdown/state'; +import clusterDropdownStoreState from '~/create_cluster/store/cluster_dropdown/state'; const localVue = createLocalVue(); localVue.use(Vuex); @@ -197,7 +197,9 @@ describe('EksClusterConfigurationForm', () => { it('sets RoleDropdown hasErrors to true when loading roles failed', () => { rolesState.loadingItemsError = new Error(); - expect(findRoleDropdown().props('hasErrors')).toEqual(true); + return Vue.nextTick().then(() => { + expect(findRoleDropdown().props('hasErrors')).toEqual(true); + }); }); it('sets isLoadingRegions to RegionDropdown loading property', () => { @@ -215,7 +217,9 @@ describe('EksClusterConfigurationForm', () => { it('sets loadingRegionsError to RegionDropdown error property', () => { regionsState.loadingItemsError = new Error(); - expect(findRegionDropdown().props('hasErrors')).toEqual(true); + return Vue.nextTick().then(() => { + expect(findRegionDropdown().props('hasErrors')).toEqual(true); + }); }); it('disables KeyPairDropdown when no region is selected', () => { @@ -245,7 +249,9 @@ describe('EksClusterConfigurationForm', () => { it('sets KeyPairDropdown hasErrors to true when loading key pairs fails', () => { keyPairsState.loadingItemsError = new Error(); - expect(findKeyPairDropdown().props('hasErrors')).toEqual(true); + return Vue.nextTick().then(() => { + expect(findKeyPairDropdown().props('hasErrors')).toEqual(true); + }); }); it('disables VpcDropdown when no region is selected', () => { @@ -275,7 +281,9 @@ describe('EksClusterConfigurationForm', () => { it('sets VpcDropdown hasErrors to true when loading vpcs fails', () => { vpcsState.loadingItemsError = new Error(); - expect(findVpcDropdown().props('hasErrors')).toEqual(true); + return Vue.nextTick().then(() => { + expect(findVpcDropdown().props('hasErrors')).toEqual(true); + }); }); it('disables SubnetDropdown when no vpc is selected', () => { @@ -305,7 +313,9 @@ describe('EksClusterConfigurationForm', () => { it('sets SubnetDropdown hasErrors to true when loading subnets fails', () => { subnetsState.loadingItemsError = new Error(); - expect(findSubnetDropdown().props('hasErrors')).toEqual(true); + return Vue.nextTick().then(() => { + expect(findSubnetDropdown().props('hasErrors')).toEqual(true); + }); }); it('disables SecurityGroupDropdown when no vpc is selected', () => { @@ -335,7 +345,9 @@ describe('EksClusterConfigurationForm', () => { it('sets SecurityGroupDropdown hasErrors to true when loading security groups fails', () => { securityGroupsState.loadingItemsError = new Error(); - expect(findSecurityGroupDropdown().props('hasErrors')).toEqual(true); + return Vue.nextTick().then(() => { + expect(findSecurityGroupDropdown().props('hasErrors')).toEqual(true); + }); }); describe('when region is selected', () => { diff --git a/spec/frontend/create_cluster/eks_cluster/components/service_credentials_form_spec.js b/spec/frontend/create_cluster/eks_cluster/components/service_credentials_form_spec.js index 0be723b48f00d9a3a581d3597c138f3c6138beaf..c58638f5c800e7afda88b76e083056952e1c2d56 100644 --- a/spec/frontend/create_cluster/eks_cluster/components/service_credentials_form_spec.js +++ b/spec/frontend/create_cluster/eks_cluster/components/service_credentials_form_spec.js @@ -47,7 +47,6 @@ describe('ServiceCredentialsForm', () => { const findCopyExternalIdButton = () => vm.find('.js-copy-external-id-button'); const findInvalidCredentials = () => vm.find('.js-invalid-credentials'); const findSubmitButton = () => vm.find(LoadingButton); - const findForm = () => vm.find('form[name="service-credentials-form"]'); it('displays provided account id', () => { expect(findAccountIdInput().attributes('value')).toBe(accountId); @@ -72,11 +71,15 @@ describe('ServiceCredentialsForm', () => { it('enables submit button when role ARN is not provided', () => { vm.setData({ roleArn: '123' }); - expect(findSubmitButton().attributes('disabled')).toBeFalsy(); + return vm.vm.$nextTick().then(() => { + expect(findSubmitButton().attributes('disabled')).toBeFalsy(); + }); }); - it('dispatches createRole action when form is submitted', () => { - findForm().trigger('submit'); + it('dispatches createRole action when submit button is clicked', () => { + vm.setData({ roleArn: '123' }); // set role ARN to enable button + + findSubmitButton().vm.$emit('click', new Event('click')); expect(createRoleAction).toHaveBeenCalled(); }); @@ -86,6 +89,8 @@ describe('ServiceCredentialsForm', () => { vm.setData({ roleArn: '123' }); // set role ARN to enable button state.isCreatingRole = true; + + return vm.vm.$nextTick(); }); it('disables submit button', () => { diff --git a/spec/frontend/create_cluster/gke_cluster/components/gke_machine_type_dropdown_spec.js b/spec/frontend/create_cluster/gke_cluster/components/gke_machine_type_dropdown_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..57ef74f0119fc08c34fc1349a7e8580f39701b60 --- /dev/null +++ b/spec/frontend/create_cluster/gke_cluster/components/gke_machine_type_dropdown_spec.js @@ -0,0 +1,135 @@ +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import Vuex from 'vuex'; +import { selectedMachineTypeMock, gapiMachineTypesResponseMock } from '../mock_data'; +import createState from '~/create_cluster/gke_cluster/store/state'; +import DropdownButton from '~/vue_shared/components/dropdown/dropdown_button.vue'; +import DropdownHiddenInput from '~/vue_shared/components/dropdown/dropdown_hidden_input.vue'; +import GkeMachineTypeDropdown from '~/create_cluster/gke_cluster/components/gke_machine_type_dropdown.vue'; + +const componentConfig = { + fieldId: 'cluster_provider_gcp_attributes_gcp_machine_type', + fieldName: 'cluster[provider_gcp_attributes][gcp_machine_type]', +}; +const setMachineType = jest.fn(); + +const LABELS = { + LOADING: 'Fetching machine types', + DISABLED_NO_PROJECT: 'Select project and zone to choose machine type', + DISABLED_NO_ZONE: 'Select zone to choose machine type', + DEFAULT: 'Select machine type', +}; + +const localVue = createLocalVue(); + +localVue.use(Vuex); + +const createComponent = (store, propsData = componentConfig) => + shallowMount(GkeMachineTypeDropdown, { + propsData, + store, + localVue, + }); + +const createStore = (initialState = {}, getters = {}) => + new Vuex.Store({ + state: { + ...createState(), + ...initialState, + }, + getters: { + hasZone: () => false, + ...getters, + }, + actions: { + setMachineType, + }, + }); + +describe('GkeMachineTypeDropdown', () => { + let wrapper; + let store; + + afterEach(() => { + wrapper.destroy(); + }); + + const dropdownButtonLabel = () => wrapper.find(DropdownButton).props('toggleText'); + const dropdownHiddenInputValue = () => wrapper.find(DropdownHiddenInput).props('value'); + + describe('shows various toggle text depending on state', () => { + it('returns disabled state toggle text when no project and zone are selected', () => { + store = createStore({ + projectHasBillingEnabled: false, + }); + wrapper = createComponent(store); + + expect(dropdownButtonLabel()).toBe(LABELS.DISABLED_NO_PROJECT); + }); + + it('returns disabled state toggle text when no zone is selected', () => { + store = createStore({ + projectHasBillingEnabled: true, + }); + wrapper = createComponent(store); + + expect(dropdownButtonLabel()).toBe(LABELS.DISABLED_NO_ZONE); + }); + + it('returns loading toggle text', () => { + store = createStore(); + wrapper = createComponent(store); + + wrapper.setData({ isLoading: true }); + + return wrapper.vm.$nextTick().then(() => { + expect(dropdownButtonLabel()).toBe(LABELS.LOADING); + }); + }); + + it('returns default toggle text', () => { + store = createStore( + { + projectHasBillingEnabled: true, + }, + { hasZone: () => true }, + ); + wrapper = createComponent(store); + + expect(dropdownButtonLabel()).toBe(LABELS.DEFAULT); + }); + + it('returns machine type name if machine type selected', () => { + store = createStore( + { + projectHasBillingEnabled: true, + selectedMachineType: selectedMachineTypeMock, + }, + { hasZone: () => true }, + ); + wrapper = createComponent(store); + + expect(dropdownButtonLabel()).toBe(selectedMachineTypeMock); + }); + }); + + describe('form input', () => { + it('reflects new value when dropdown item is clicked', () => { + store = createStore({ + machineTypes: gapiMachineTypesResponseMock.items, + }); + wrapper = createComponent(store); + + expect(dropdownHiddenInputValue()).toBe(''); + + wrapper.find('.dropdown-content button').trigger('click'); + + return wrapper.vm.$nextTick().then(() => { + expect(setMachineType).toHaveBeenCalledWith( + expect.anything(), + selectedMachineTypeMock, + undefined, + ); + }); + }); + }); +}); diff --git a/spec/frontend/create_cluster/gke_cluster/components/gke_network_dropdown_spec.js b/spec/frontend/create_cluster/gke_cluster/components/gke_network_dropdown_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..1df583af7110f5930b67d8b4a1f1d12467685397 --- /dev/null +++ b/spec/frontend/create_cluster/gke_cluster/components/gke_network_dropdown_spec.js @@ -0,0 +1,143 @@ +import Vuex from 'vuex'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import GkeNetworkDropdown from '~/create_cluster/gke_cluster/components/gke_network_dropdown.vue'; +import ClusterFormDropdown from '~/create_cluster/components/cluster_form_dropdown.vue'; +import createClusterDropdownState from '~/create_cluster/store/cluster_dropdown/state'; + +const localVue = createLocalVue(); + +localVue.use(Vuex); + +describe('GkeNetworkDropdown', () => { + let wrapper; + let store; + const defaultProps = { fieldName: 'field-name' }; + const selectedNetwork = { selfLink: '123456' }; + const projectId = '6789'; + const region = 'east-1'; + const setNetwork = jest.fn(); + const setSubnetwork = jest.fn(); + const fetchSubnetworks = jest.fn(); + + const buildStore = ({ clusterDropdownState } = {}) => + new Vuex.Store({ + state: { + selectedNetwork, + }, + actions: { + setNetwork, + setSubnetwork, + }, + getters: { + hasZone: () => false, + region: () => region, + projectId: () => projectId, + }, + modules: { + networks: { + namespaced: true, + state: { + ...createClusterDropdownState(), + ...(clusterDropdownState || {}), + }, + }, + subnetworks: { + namespaced: true, + actions: { + fetchItems: fetchSubnetworks, + }, + }, + }, + }); + + const buildWrapper = (propsData = defaultProps) => + shallowMount(GkeNetworkDropdown, { + propsData, + store, + localVue, + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('sets correct field-name', () => { + const fieldName = 'field-name'; + + store = buildStore(); + wrapper = buildWrapper({ fieldName }); + + expect(wrapper.find(ClusterFormDropdown).props('fieldName')).toBe(fieldName); + }); + + it('sets selected network as the dropdown value', () => { + store = buildStore(); + wrapper = buildWrapper(); + + expect(wrapper.find(ClusterFormDropdown).props('value')).toBe(selectedNetwork); + }); + + it('maps networks store items to the dropdown items property', () => { + const items = [{ name: 'network' }]; + + store = buildStore({ clusterDropdownState: { items } }); + wrapper = buildWrapper(); + + expect(wrapper.find(ClusterFormDropdown).props('items')).toBe(items); + }); + + describe('when network dropdown store is loading items', () => { + it('sets network dropdown as loading', () => { + store = buildStore({ clusterDropdownState: { isLoadingItems: true } }); + wrapper = buildWrapper(); + + expect(wrapper.find(ClusterFormDropdown).props('loading')).toBe(true); + }); + }); + + describe('when there is no selected zone', () => { + it('disables the network dropdown', () => { + store = buildStore(); + wrapper = buildWrapper(); + + expect(wrapper.find(ClusterFormDropdown).props('disabled')).toBe(true); + }); + }); + + describe('when an error occurs while loading networks', () => { + it('sets the network dropdown as having errors', () => { + store = buildStore({ clusterDropdownState: { loadingItemsError: new Error() } }); + wrapper = buildWrapper(); + + expect(wrapper.find(ClusterFormDropdown).props('hasErrors')).toBe(true); + }); + }); + + describe('when dropdown emits input event', () => { + beforeEach(() => { + store = buildStore(); + wrapper = buildWrapper(); + wrapper.find(ClusterFormDropdown).vm.$emit('input', selectedNetwork); + }); + + it('cleans selected subnetwork', () => { + expect(setSubnetwork).toHaveBeenCalledWith(expect.anything(), '', undefined); + }); + + it('dispatches the setNetwork action', () => { + expect(setNetwork).toHaveBeenCalledWith(expect.anything(), selectedNetwork, undefined); + }); + + it('fetches subnetworks for the selected project, region, and network', () => { + expect(fetchSubnetworks).toHaveBeenCalledWith( + expect.anything(), + { + project: projectId, + region, + network: selectedNetwork.selfLink, + }, + undefined, + ); + }); + }); +}); diff --git a/spec/frontend/create_cluster/gke_cluster/components/gke_project_id_dropdown_spec.js b/spec/frontend/create_cluster/gke_cluster/components/gke_project_id_dropdown_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..0d429778a449bdd7ce50039da07f948328616c7c --- /dev/null +++ b/spec/frontend/create_cluster/gke_cluster/components/gke_project_id_dropdown_spec.js @@ -0,0 +1,138 @@ +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import Vuex from 'vuex'; +import createState from '~/create_cluster/gke_cluster/store/state'; +import { selectedProjectMock, gapiProjectsResponseMock } from '../mock_data'; +import GkeProjectIdDropdown from '~/create_cluster/gke_cluster/components/gke_project_id_dropdown.vue'; +import DropdownButton from '~/vue_shared/components/dropdown/dropdown_button.vue'; +import DropdownHiddenInput from '~/vue_shared/components/dropdown/dropdown_hidden_input.vue'; + +const componentConfig = { + docsUrl: 'https://console.cloud.google.com/home/dashboard', + fieldId: 'cluster_provider_gcp_attributes_gcp_project_id', + fieldName: 'cluster[provider_gcp_attributes][gcp_project_id]', +}; + +const LABELS = { + LOADING: 'Fetching projects', + VALIDATING_PROJECT_BILLING: 'Validating project billing status', + DEFAULT: 'Select project', + EMPTY: 'No projects found', +}; + +const localVue = createLocalVue(); + +localVue.use(Vuex); + +describe('GkeProjectIdDropdown', () => { + let wrapper; + let vuexStore; + let setProject; + + beforeEach(() => { + setProject = jest.fn(); + }); + + const createStore = (initialState = {}, getters = {}) => + new Vuex.Store({ + state: { + ...createState(), + ...initialState, + }, + actions: { + fetchProjects: jest.fn().mockResolvedValueOnce([]), + setProject, + }, + getters: { + hasProject: () => false, + ...getters, + }, + }); + + const createComponent = (store, propsData = componentConfig) => + shallowMount(GkeProjectIdDropdown, { + propsData, + store, + localVue, + }); + + const bootstrap = (initialState, getters) => { + vuexStore = createStore(initialState, getters); + wrapper = createComponent(vuexStore); + }; + + const dropdownButtonLabel = () => wrapper.find(DropdownButton).props('toggleText'); + const dropdownHiddenInputValue = () => wrapper.find(DropdownHiddenInput).props('value'); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('toggleText', () => { + it('returns loading toggle text', () => { + bootstrap(); + + expect(dropdownButtonLabel()).toBe(LABELS.LOADING); + }); + + it('returns project billing validation text', () => { + bootstrap({ isValidatingProjectBilling: true }); + + expect(dropdownButtonLabel()).toBe(LABELS.VALIDATING_PROJECT_BILLING); + }); + + it('returns default toggle text', () => { + bootstrap(); + + wrapper.setData({ isLoading: false }); + + return wrapper.vm.$nextTick().then(() => { + expect(dropdownButtonLabel()).toBe(LABELS.DEFAULT); + }); + }); + + it('returns project name if project selected', () => { + bootstrap( + { + selectedProject: selectedProjectMock, + }, + { + hasProject: () => true, + }, + ); + wrapper.setData({ isLoading: false }); + + return wrapper.vm.$nextTick().then(() => { + expect(dropdownButtonLabel()).toBe(selectedProjectMock.name); + }); + }); + + it('returns empty toggle text', () => { + bootstrap({ + projects: null, + }); + wrapper.setData({ isLoading: false }); + + return wrapper.vm.$nextTick().then(() => { + expect(dropdownButtonLabel()).toBe(LABELS.EMPTY); + }); + }); + }); + + describe('selectItem', () => { + it('reflects new value when dropdown item is clicked', () => { + bootstrap({ projects: gapiProjectsResponseMock.projects }); + + expect(dropdownHiddenInputValue()).toBe(''); + + wrapper.find('.dropdown-content button').trigger('click'); + + return wrapper.vm.$nextTick().then(() => { + expect(setProject).toHaveBeenCalledWith( + expect.anything(), + gapiProjectsResponseMock.projects[0], + undefined, + ); + }); + }); + }); +}); diff --git a/spec/frontend/create_cluster/gke_cluster/components/gke_subnetwork_dropdown_spec.js b/spec/frontend/create_cluster/gke_cluster/components/gke_subnetwork_dropdown_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..a1dc3960fe9fe0b943e4f6ad0b424837f81e3bc4 --- /dev/null +++ b/spec/frontend/create_cluster/gke_cluster/components/gke_subnetwork_dropdown_spec.js @@ -0,0 +1,113 @@ +import Vuex from 'vuex'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import GkeSubnetworkDropdown from '~/create_cluster/gke_cluster/components/gke_subnetwork_dropdown.vue'; +import ClusterFormDropdown from '~/create_cluster/components/cluster_form_dropdown.vue'; +import createClusterDropdownState from '~/create_cluster/store/cluster_dropdown/state'; + +const localVue = createLocalVue(); + +localVue.use(Vuex); + +describe('GkeSubnetworkDropdown', () => { + let wrapper; + let store; + const defaultProps = { fieldName: 'field-name' }; + const selectedSubnetwork = '123456'; + const setSubnetwork = jest.fn(); + + const buildStore = ({ clusterDropdownState } = {}) => + new Vuex.Store({ + state: { + selectedSubnetwork, + }, + actions: { + setSubnetwork, + }, + getters: { + hasNetwork: () => false, + }, + modules: { + subnetworks: { + namespaced: true, + state: { + ...createClusterDropdownState(), + ...(clusterDropdownState || {}), + }, + }, + }, + }); + + const buildWrapper = (propsData = defaultProps) => + shallowMount(GkeSubnetworkDropdown, { + propsData, + store, + localVue, + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('sets correct field-name', () => { + const fieldName = 'field-name'; + + store = buildStore(); + wrapper = buildWrapper({ fieldName }); + + expect(wrapper.find(ClusterFormDropdown).props('fieldName')).toBe(fieldName); + }); + + it('sets selected subnetwork as the dropdown value', () => { + store = buildStore(); + wrapper = buildWrapper(); + + expect(wrapper.find(ClusterFormDropdown).props('value')).toBe(selectedSubnetwork); + }); + + it('maps subnetworks store items to the dropdown items property', () => { + const items = [{ name: 'subnetwork' }]; + + store = buildStore({ clusterDropdownState: { items } }); + wrapper = buildWrapper(); + + expect(wrapper.find(ClusterFormDropdown).props('items')).toBe(items); + }); + + describe('when subnetwork dropdown store is loading items', () => { + it('sets subnetwork dropdown as loading', () => { + store = buildStore({ clusterDropdownState: { isLoadingItems: true } }); + wrapper = buildWrapper(); + + expect(wrapper.find(ClusterFormDropdown).props('loading')).toBe(true); + }); + }); + + describe('when there is no selected network', () => { + it('disables the subnetwork dropdown', () => { + store = buildStore(); + wrapper = buildWrapper(); + + expect(wrapper.find(ClusterFormDropdown).props('disabled')).toBe(true); + }); + }); + + describe('when an error occurs while loading subnetworks', () => { + it('sets the subnetwork dropdown as having errors', () => { + store = buildStore({ clusterDropdownState: { loadingItemsError: new Error() } }); + wrapper = buildWrapper(); + + expect(wrapper.find(ClusterFormDropdown).props('hasErrors')).toBe(true); + }); + }); + + describe('when dropdown emits input event', () => { + it('dispatches the setSubnetwork action', () => { + store = buildStore(); + wrapper = buildWrapper(); + + wrapper.find(ClusterFormDropdown).vm.$emit('input', selectedSubnetwork); + + expect(setSubnetwork).toHaveBeenCalledWith(expect.anything(), selectedSubnetwork, undefined); + }); + }); +}); diff --git a/spec/frontend/create_cluster/gke_cluster/mock_data.js b/spec/frontend/create_cluster/gke_cluster/mock_data.js new file mode 100644 index 0000000000000000000000000000000000000000..d9f5dbc636fbf7f4688a93cf679077eb85bf8a96 --- /dev/null +++ b/spec/frontend/create_cluster/gke_cluster/mock_data.js @@ -0,0 +1,75 @@ +export const emptyProjectMock = { + projectId: '', + name: '', +}; + +export const selectedProjectMock = { + projectId: 'gcp-project-123', + name: 'gcp-project', +}; + +export const selectedZoneMock = 'us-central1-a'; + +export const selectedMachineTypeMock = 'n1-standard-2'; + +export const gapiProjectsResponseMock = { + projects: [ + { + projectNumber: '1234', + projectId: 'gcp-project-123', + lifecycleState: 'ACTIVE', + name: 'gcp-project', + createTime: '2017-12-16T01:48:29.129Z', + parent: { + type: 'organization', + id: '12345', + }, + }, + ], +}; + +export const gapiZonesResponseMock = { + kind: 'compute#zoneList', + id: 'projects/gitlab-internal-153318/zones', + items: [ + { + kind: 'compute#zone', + id: '2000', + creationTimestamp: '1969-12-31T16:00:00.000-08:00', + name: 'us-central1-a', + description: 'us-central1-a', + status: 'UP', + region: + 'https://www.googleapis.com/compute/v1/projects/gitlab-internal-153318/regions/us-central1', + selfLink: + 'https://www.googleapis.com/compute/v1/projects/gitlab-internal-153318/zones/us-central1-a', + availableCpuPlatforms: ['Intel Skylake', 'Intel Broadwell', 'Intel Sandy Bridge'], + }, + ], + selfLink: 'https://www.googleapis.com/compute/v1/projects/gitlab-internal-153318/zones', +}; + +export const gapiMachineTypesResponseMock = { + kind: 'compute#machineTypeList', + id: 'projects/gitlab-internal-153318/zones/us-central1-a/machineTypes', + items: [ + { + kind: 'compute#machineType', + id: '3002', + creationTimestamp: '1969-12-31T16:00:00.000-08:00', + name: 'n1-standard-2', + description: '2 vCPUs, 7.5 GB RAM', + guestCpus: 2, + memoryMb: 7680, + imageSpaceGb: 10, + maximumPersistentDisks: 64, + maximumPersistentDisksSizeGb: '65536', + zone: 'us-central1-a', + selfLink: + 'https://www.googleapis.com/compute/v1/projects/gitlab-internal-153318/zones/us-central1-a/machineTypes/n1-standard-2', + isSharedCpu: false, + }, + ], + selfLink: + 'https://www.googleapis.com/compute/v1/projects/gitlab-internal-153318/zones/us-central1-a/machineTypes', +}; diff --git a/spec/frontend/create_cluster/eks_cluster/store/cluster_dropdown/actions_spec.js b/spec/frontend/create_cluster/store/cluster_dropdown/actions_spec.js similarity index 89% rename from spec/frontend/create_cluster/eks_cluster/store/cluster_dropdown/actions_spec.js rename to spec/frontend/create_cluster/store/cluster_dropdown/actions_spec.js index 58f8855a64c901a7e3f94937de3fa3e06c0ef474..014b527161f3adbd6596a0f93ac4f250445f8720 100644 --- a/spec/frontend/create_cluster/eks_cluster/store/cluster_dropdown/actions_spec.js +++ b/spec/frontend/create_cluster/store/cluster_dropdown/actions_spec.js @@ -1,8 +1,8 @@ import testAction from 'helpers/vuex_action_helper'; -import createState from '~/create_cluster/eks_cluster/store/cluster_dropdown/state'; -import * as types from '~/create_cluster/eks_cluster/store/cluster_dropdown/mutation_types'; -import actionsFactory from '~/create_cluster/eks_cluster/store/cluster_dropdown/actions'; +import createState from '~/create_cluster/store/cluster_dropdown/state'; +import * as types from '~/create_cluster/store/cluster_dropdown/mutation_types'; +import actionsFactory from '~/create_cluster/store/cluster_dropdown/actions'; describe('Cluster dropdown Store Actions', () => { const items = [{ name: 'item 1' }]; diff --git a/spec/frontend/create_cluster/eks_cluster/store/cluster_dropdown/mutations_spec.js b/spec/frontend/create_cluster/store/cluster_dropdown/mutations_spec.js similarity index 84% rename from spec/frontend/create_cluster/eks_cluster/store/cluster_dropdown/mutations_spec.js rename to spec/frontend/create_cluster/store/cluster_dropdown/mutations_spec.js index 0665047edeaa9100fcafe02a04122fdaa69795b3..5edd237133d39613fc554b41efa33eaad7562298 100644 --- a/spec/frontend/create_cluster/eks_cluster/store/cluster_dropdown/mutations_spec.js +++ b/spec/frontend/create_cluster/store/cluster_dropdown/mutations_spec.js @@ -2,9 +2,9 @@ import { REQUEST_ITEMS, RECEIVE_ITEMS_SUCCESS, RECEIVE_ITEMS_ERROR, -} from '~/create_cluster/eks_cluster/store/cluster_dropdown/mutation_types'; -import createState from '~/create_cluster/eks_cluster/store/cluster_dropdown/state'; -import mutations from '~/create_cluster/eks_cluster/store/cluster_dropdown/mutations'; +} from '~/create_cluster/store/cluster_dropdown/mutation_types'; +import createState from '~/create_cluster/store/cluster_dropdown/state'; +import mutations from '~/create_cluster/store/cluster_dropdown/mutations'; describe('Cluster dropdown store mutations', () => { let state; diff --git a/spec/frontend/cycle_analytics/limit_warning_component_spec.js b/spec/frontend/cycle_analytics/limit_warning_component_spec.js index 5041ebe1a8b9f726ed4e8f9b4c41f91170d7e287..e712dea67cb5a5c11c26b77bc243102573d66af7 100644 --- a/spec/frontend/cycle_analytics/limit_warning_component_spec.js +++ b/spec/frontend/cycle_analytics/limit_warning_component_spec.js @@ -10,8 +10,6 @@ const createComponent = props => propsData: { ...props, }, - sync: false, - attachToDocument: true, }); describe('Limit warning component', () => { diff --git a/spec/frontend/cycle_analytics/stage_nav_item_spec.js b/spec/frontend/cycle_analytics/stage_nav_item_spec.js index a7a1d563e1e3bbbf4b88717f21fdd9842f8195ac..480bb75673131fbb4b80b2e1eb36db2734f9deb8 100644 --- a/spec/frontend/cycle_analytics/stage_nav_item_spec.js +++ b/spec/frontend/cycle_analytics/stage_nav_item_spec.js @@ -92,7 +92,9 @@ describe('StageNavItem', () => { it('emits the `select` event when clicked', () => { expect(wrapper.emitted().select).toBeUndefined(); wrapper.trigger('click'); - expect(wrapper.emitted().select.length).toBe(1); + return wrapper.vm.$nextTick(() => { + expect(wrapper.emitted().select.length).toBe(1); + }); }); }); diff --git a/spec/frontend/diffs/components/compare_versions_spec.js b/spec/frontend/diffs/components/compare_versions_spec.js index 9900fcdb6e10e40360107bbbd926c8c8be20d3e5..ff92a12eaf633ca1743f667ba6d603ceea35807a 100644 --- a/spec/frontend/diffs/components/compare_versions_spec.js +++ b/spec/frontend/diffs/components/compare_versions_spec.js @@ -22,13 +22,12 @@ describe('CompareVersions', () => { store.state.diffs.diffFiles.push('test'); wrapper = mount(CompareVersionsComponent, { - sync: false, - attachToDocument: true, localVue, store, propsData: { mergeRequestDiffs: diffsMockData, mergeRequestDiff: diffsMockData[0], + diffFilesLength: 0, targetBranch, ...props, }, @@ -49,9 +48,8 @@ describe('CompareVersions', () => { const treeListBtn = wrapper.find('.js-toggle-tree-list'); expect(treeListBtn.exists()).toBe(true); - expect(treeListBtn.attributes('data-original-title')).toBe('Hide file browser'); - expect(treeListBtn.findAll(Icon).length).not.toBe(0); - expect(treeListBtn.find(Icon).props('name')).toBe('collapse-left'); + expect(treeListBtn.attributes('title')).toBe('Hide file browser'); + expect(treeListBtn.find(Icon).props('name')).toBe('file-tree'); }); it('should render comparison dropdowns with correct values', () => { diff --git a/spec/frontend/diffs/components/diff_content_spec.js b/spec/frontend/diffs/components/diff_content_spec.js index b0dd25f746b79c0062743764280f97c4bc5e60fe..979c67787f7ed3970f01b21eac3c5a02b7457a66 100644 --- a/spec/frontend/diffs/components/diff_content_spec.js +++ b/spec/frontend/diffs/components/diff_content_spec.js @@ -84,7 +84,6 @@ describe('DiffContent', () => { }, localVue, store: fakeStore, - sync: false, }); }; diff --git a/spec/frontend/diffs/components/diff_discussion_reply_spec.js b/spec/frontend/diffs/components/diff_discussion_reply_spec.js index 28689ab07ded45171494d90244550bd56a4e2ad8..9443a441ec24a7bcf403daead58f92d6009fe12a 100644 --- a/spec/frontend/diffs/components/diff_discussion_reply_spec.js +++ b/spec/frontend/diffs/components/diff_discussion_reply_spec.js @@ -16,7 +16,6 @@ describe('DiffDiscussionReply', () => { wrapper = shallowMount(DiffDiscussionReply, { store, localVue, - sync: false, propsData: { ...props, }, diff --git a/spec/frontend/diffs/components/diff_file_header_spec.js b/spec/frontend/diffs/components/diff_file_header_spec.js index 48fd6dd6f58c27359ff3934a908a30e7c152e77e..e0b7e0bc0f3f1aa3fe08ced0c5fcc4e02911646a 100644 --- a/spec/frontend/diffs/components/diff_file_header_spec.js +++ b/spec/frontend/diffs/components/diff_file_header_spec.js @@ -91,8 +91,6 @@ describe('DiffFileHeader component', () => { }, localVue, store, - sync: false, - attachToDocument: true, }); }; @@ -117,19 +115,27 @@ describe('DiffFileHeader component', () => { it('when header is clicked emits toggleFile', () => { createComponent(); findHeader().trigger('click'); - expect(wrapper.emitted().toggleFile).toBeDefined(); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.emitted().toggleFile).toBeDefined(); + }); }); it('when collapseIcon is clicked emits toggleFile', () => { createComponent({ collapsible: true }); findCollapseIcon().vm.$emit('click', new Event('click')); - expect(wrapper.emitted().toggleFile).toBeDefined(); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.emitted().toggleFile).toBeDefined(); + }); }); it('when other element in header is clicked does not emits toggleFile', () => { createComponent({ collapsible: true }); findTitleLink().trigger('click'); - expect(wrapper.emitted().toggleFile).not.toBeDefined(); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.emitted().toggleFile).not.toBeDefined(); + }); }); it('displays a copy to clipboard button', () => { @@ -194,7 +200,9 @@ describe('DiffFileHeader component', () => { addMergeRequestButtons: true, }); wrapper.find(EditButton).vm.$emit('showForkMessage'); - expect(wrapper.emitted().showForkMessage).toBeDefined(); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.emitted().showForkMessage).toBeDefined(); + }); }); it('for mode_changed file mode displays mode changes', () => { @@ -329,7 +337,7 @@ describe('DiffFileHeader component', () => { addMergeRequestButtons: true, }); expect(findViewFileButton().attributes('href')).toBe(viewPath); - expect(findViewFileButton().attributes('data-original-title')).toEqual( + expect(findViewFileButton().attributes('title')).toEqual( `View file @ ${diffFile.content_sha.substr(0, 8)}`, ); }); diff --git a/spec/frontend/diffs/components/diff_gutter_avatars_spec.js b/spec/frontend/diffs/components/diff_gutter_avatars_spec.js index b2debe36b8901f372375d3584b90783270b22631..4d8345d494d6a81cbb024b6842615af06c1b248f 100644 --- a/spec/frontend/diffs/components/diff_gutter_avatars_spec.js +++ b/spec/frontend/diffs/components/diff_gutter_avatars_spec.js @@ -1,8 +1,7 @@ -import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; import DiffGutterAvatars from '~/diffs/components/diff_gutter_avatars.vue'; import discussionsMockData from '../mock_data/diff_discussions'; -const localVue = createLocalVue(); const getDiscussionsMockData = () => [Object.assign({}, discussionsMockData)]; describe('DiffGutterAvatars', () => { @@ -14,12 +13,9 @@ describe('DiffGutterAvatars', () => { const createComponent = (props = {}) => { wrapper = shallowMount(DiffGutterAvatars, { - localVue, propsData: { ...props, }, - sync: false, - attachToDocument: true, }); }; @@ -42,7 +38,9 @@ describe('DiffGutterAvatars', () => { it('should emit toggleDiscussions event on button click', () => { findCollapseButton().trigger('click'); - expect(wrapper.emitted().toggleLineDiscussions).toBeTruthy(); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.emitted().toggleLineDiscussions).toBeTruthy(); + }); }); }); @@ -72,13 +70,17 @@ describe('DiffGutterAvatars', () => { .at(0) .trigger('click'); - expect(wrapper.emitted().toggleLineDiscussions).toBeTruthy(); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.emitted().toggleLineDiscussions).toBeTruthy(); + }); }); it('should emit toggleDiscussions event on more count text click', () => { findMoreCount().trigger('click'); - expect(wrapper.emitted().toggleLineDiscussions).toBeTruthy(); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.emitted().toggleLineDiscussions).toBeTruthy(); + }); }); }); diff --git a/spec/frontend/diffs/components/diff_stats_spec.js b/spec/frontend/diffs/components/diff_stats_spec.js index 984b3026209accd9c803909ec9405059df4fa90d..aa5c7f6278a9153f7b6154f3d2c477260ea2e6a2 100644 --- a/spec/frontend/diffs/components/diff_stats_spec.js +++ b/spec/frontend/diffs/components/diff_stats_spec.js @@ -22,12 +22,12 @@ describe('diff_stats', () => { diffFilesLength: 300, }, }); - const additions = wrapper.find('icon-stub[name="file-addition"]').element.parentNode; - const deletions = wrapper.find('icon-stub[name="file-deletion"]').element.parentNode; - const filesChanged = wrapper.find('icon-stub[name="doc-code"]').element.parentNode; - expect(additions.textContent).toContain('100'); - expect(deletions.textContent).toContain('200'); - expect(filesChanged.textContent).toContain('300'); + const findFileLine = name => wrapper.find(name); + const additions = findFileLine('.js-file-addition-line'); + const deletions = findFileLine('.js-file-deletion-line'); + + expect(additions.text()).toBe('100'); + expect(deletions.text()).toBe('200'); }); }); diff --git a/spec/frontend/diffs/components/edit_button_spec.js b/spec/frontend/diffs/components/edit_button_spec.js index 4e2cfc75212b05511c75fc70f0a0d3f383fe3019..f9a1d4a84a819c48f519e488ec2b5fc023dbb72e 100644 --- a/spec/frontend/diffs/components/edit_button_spec.js +++ b/spec/frontend/diffs/components/edit_button_spec.js @@ -1,7 +1,6 @@ -import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; import EditButton from '~/diffs/components/edit_button.vue'; -const localVue = createLocalVue(); const editPath = 'test-path'; describe('EditButton', () => { @@ -9,10 +8,7 @@ describe('EditButton', () => { const createComponent = (props = {}) => { wrapper = shallowMount(EditButton, { - localVue, propsData: { ...props }, - sync: false, - attachToDocument: true, }); }; @@ -36,7 +32,9 @@ describe('EditButton', () => { }); wrapper.trigger('click'); - expect(wrapper.emitted('showForkMessage')).toBeTruthy(); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.emitted('showForkMessage')).toBeTruthy(); + }); }); it('doesnt emit a show fork message event if current user cannot fork', () => { @@ -46,7 +44,9 @@ describe('EditButton', () => { }); wrapper.trigger('click'); - expect(wrapper.emitted('showForkMessage')).toBeFalsy(); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.emitted('showForkMessage')).toBeFalsy(); + }); }); it('doesnt emit a show fork message event if current user can modify blob', () => { @@ -57,6 +57,8 @@ describe('EditButton', () => { }); wrapper.trigger('click'); - expect(wrapper.emitted('showForkMessage')).toBeFalsy(); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.emitted('showForkMessage')).toBeFalsy(); + }); }); }); diff --git a/spec/frontend/diffs/components/hidden_files_warning_spec.js b/spec/frontend/diffs/components/hidden_files_warning_spec.js index 5bf5ddd27bde84460a675f45ec4343b1080e611b..6fb4e4645f8a005388eec7306608ace8ddaaf9a6 100644 --- a/spec/frontend/diffs/components/hidden_files_warning_spec.js +++ b/spec/frontend/diffs/components/hidden_files_warning_spec.js @@ -1,7 +1,6 @@ -import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; import HiddenFilesWarning from '~/diffs/components/hidden_files_warning.vue'; -const localVue = createLocalVue(); const propsData = { total: '10', visible: 5, @@ -14,8 +13,6 @@ describe('HiddenFilesWarning', () => { const createComponent = () => { wrapper = shallowMount(HiddenFilesWarning, { - localVue, - sync: false, propsData, }); }; diff --git a/spec/frontend/diffs/components/no_changes_spec.js b/spec/frontend/diffs/components/no_changes_spec.js index e45d34bf9d5fce446afcf0ee87aa8ee59d9f489e..245651af61c97cf7bcc6b1185daf3eea92b17763 100644 --- a/spec/frontend/diffs/components/no_changes_spec.js +++ b/spec/frontend/diffs/components/no_changes_spec.js @@ -13,7 +13,7 @@ describe('Diff no changes empty state', () => { const store = createStore(); extendStore(store); - vm = shallowMount(localVue.extend(NoChanges), { + vm = shallowMount(NoChanges, { localVue, store, propsData: { diff --git a/spec/javascripts/diffs/components/settings_dropdown_spec.js b/spec/frontend/diffs/components/settings_dropdown_spec.js similarity index 87% rename from spec/javascripts/diffs/components/settings_dropdown_spec.js rename to spec/frontend/diffs/components/settings_dropdown_spec.js index 6c08474ffd278e78fe7133febd369a83ab4001bf..2e95d79ea49604fa198a8ba7a77ed20d731cd01c 100644 --- a/spec/javascripts/diffs/components/settings_dropdown_spec.js +++ b/spec/frontend/diffs/components/settings_dropdown_spec.js @@ -25,19 +25,18 @@ describe('Diff settiings dropdown component', () => { extendStore(store); - vm = mount(localVue.extend(SettingsDropdown), { + vm = mount(SettingsDropdown, { localVue, store, - sync: false, }); } beforeEach(() => { actions = { - setInlineDiffViewType: jasmine.createSpy('setInlineDiffViewType'), - setParallelDiffViewType: jasmine.createSpy('setParallelDiffViewType'), - setRenderTreeList: jasmine.createSpy('setRenderTreeList'), - setShowWhitespace: jasmine.createSpy('setShowWhitespace'), + setInlineDiffViewType: jest.fn(), + setParallelDiffViewType: jest.fn(), + setRenderTreeList: jest.fn(), + setShowWhitespace: jest.fn(), }; }); @@ -51,7 +50,7 @@ describe('Diff settiings dropdown component', () => { vm.find('.js-list-view').trigger('click'); - expect(actions.setRenderTreeList).toHaveBeenCalledWith(jasmine.anything(), false, undefined); + expect(actions.setRenderTreeList).toHaveBeenCalledWith(expect.anything(), false, undefined); }); it('tree view button dispatches setRenderTreeList with true', () => { @@ -59,7 +58,7 @@ describe('Diff settiings dropdown component', () => { vm.find('.js-tree-view').trigger('click'); - expect(actions.setRenderTreeList).toHaveBeenCalledWith(jasmine.anything(), true, undefined); + expect(actions.setRenderTreeList).toHaveBeenCalledWith(expect.anything(), true, undefined); }); it('sets list button as active when renderTreeList is false', () => { @@ -155,7 +154,7 @@ describe('Diff settiings dropdown component', () => { checkbox.trigger('change'); expect(actions.setShowWhitespace).toHaveBeenCalledWith( - jasmine.anything(), + expect.anything(), { showWhitespace: true, pushState: true, diff --git a/spec/frontend/droplab/constants_spec.js b/spec/frontend/droplab/constants_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..fd48228d6a2e53abdf3a9179a30ff1c6aadfb4f5 --- /dev/null +++ b/spec/frontend/droplab/constants_spec.js @@ -0,0 +1,39 @@ +import * as constants from '~/droplab/constants'; + +describe('constants', () => { + describe('DATA_TRIGGER', () => { + it('should be `data-dropdown-trigger`', () => { + expect(constants.DATA_TRIGGER).toBe('data-dropdown-trigger'); + }); + }); + + describe('DATA_DROPDOWN', () => { + it('should be `data-dropdown`', () => { + expect(constants.DATA_DROPDOWN).toBe('data-dropdown'); + }); + }); + + describe('SELECTED_CLASS', () => { + it('should be `droplab-item-selected`', () => { + expect(constants.SELECTED_CLASS).toBe('droplab-item-selected'); + }); + }); + + describe('ACTIVE_CLASS', () => { + it('should be `droplab-item-active`', () => { + expect(constants.ACTIVE_CLASS).toBe('droplab-item-active'); + }); + }); + + describe('TEMPLATE_REGEX', () => { + it('should be a handlebars templating syntax regex', () => { + expect(constants.TEMPLATE_REGEX).toEqual(/\{\{(.+?)\}\}/g); + }); + }); + + describe('IGNORE_CLASS', () => { + it('should be `droplab-item-ignore`', () => { + expect(constants.IGNORE_CLASS).toBe('droplab-item-ignore'); + }); + }); +}); diff --git a/spec/javascripts/droplab/plugins/ajax_filter_spec.js b/spec/frontend/droplab/plugins/ajax_filter_spec.js similarity index 81% rename from spec/javascripts/droplab/plugins/ajax_filter_spec.js rename to spec/frontend/droplab/plugins/ajax_filter_spec.js index 5dbe50af07fd0462461597668f396a0201b5af2d..5ec0400cbc5dd7f848cf376b4eb896d17b2a4190 100644 --- a/spec/javascripts/droplab/plugins/ajax_filter_spec.js +++ b/spec/frontend/droplab/plugins/ajax_filter_spec.js @@ -28,10 +28,10 @@ describe('AjaxFilter', () => { let ajaxSpy; beforeEach(() => { - spyOn(AjaxCache, 'retrieve').and.callFake(url => ajaxSpy(url)); - spyOn(AjaxFilter, '_loadData'); + jest.spyOn(AjaxCache, 'retrieve').mockImplementation(url => ajaxSpy(url)); + jest.spyOn(AjaxFilter, '_loadData').mockImplementation(() => {}); - dummyConfig.onLoadingFinished = jasmine.createSpy('spy'); + dummyConfig.onLoadingFinished = jest.fn(); const dynamicList = document.createElement('div'); dynamicList.dataset.dynamic = true; @@ -46,7 +46,7 @@ describe('AjaxFilter', () => { AjaxFilter.trigger() .then(() => { - expect(dummyConfig.onLoadingFinished.calls.count()).toBe(1); + expect(dummyConfig.onLoadingFinished.mock.calls.length).toBe(1); }) .then(done) .catch(done.fail); @@ -63,7 +63,7 @@ describe('AjaxFilter', () => { .then(done.fail) .catch(error => { expect(error).toBe(dummyError); - expect(dummyConfig.onLoadingFinished.calls.count()).toBe(0); + expect(dummyConfig.onLoadingFinished.mock.calls.length).toBe(0); }) .then(done) .catch(done.fail); diff --git a/spec/javascripts/droplab/plugins/ajax_spec.js b/spec/frontend/droplab/plugins/ajax_spec.js similarity index 73% rename from spec/javascripts/droplab/plugins/ajax_spec.js rename to spec/frontend/droplab/plugins/ajax_spec.js index 2f492d00c0a55e9f4e0cb0bb0b10d687068e618b..1d7576ce420f096340abaf631c5f3e73401c5a6a 100644 --- a/spec/javascripts/droplab/plugins/ajax_spec.js +++ b/spec/frontend/droplab/plugins/ajax_spec.js @@ -18,23 +18,23 @@ describe('Ajax', () => { beforeEach(() => { config.preprocessing = () => processedArray; - spyOn(config, 'preprocessing').and.callFake(() => processedArray); + jest.spyOn(config, 'preprocessing').mockImplementation(() => processedArray); }); it('calls preprocessing', () => { Ajax.preprocessing(config, []); - expect(config.preprocessing.calls.count()).toBe(1); + expect(config.preprocessing.mock.calls.length).toBe(1); }); it('overrides AjaxCache', () => { - spyOn(AjaxCache, 'override').and.callFake((endpoint, results) => { + jest.spyOn(AjaxCache, 'override').mockImplementation((endpoint, results) => { expect(results).toEqual(processedArray); }); Ajax.preprocessing(config, []); - expect(AjaxCache.override.calls.count()).toBe(1); + expect(AjaxCache.override.mock.calls.length).toBe(1); }); }); }); diff --git a/spec/frontend/environments/environment_item_spec.js b/spec/frontend/environments/environment_item_spec.js index 52625c64a1c2972058a9494a532e1a9b1f9b7d4e..004687fcf443e8682a15a8eef32978cddbf493d2 100644 --- a/spec/frontend/environments/environment_item_spec.js +++ b/spec/frontend/environments/environment_item_spec.js @@ -1,6 +1,8 @@ import { mount } from '@vue/test-utils'; import { format } from 'timeago.js'; import EnvironmentItem from '~/environments/components/environment_item.vue'; +import PinComponent from '~/environments/components/environment_pin.vue'; + import { environment, folder, tableData } from './mock_data'; describe('Environment item', () => { @@ -26,6 +28,8 @@ describe('Environment item', () => { }); }); + const findAutoStop = () => wrapper.find('.js-auto-stop'); + afterEach(() => { wrapper.destroy(); }); @@ -77,6 +81,79 @@ describe('Environment item', () => { expect(wrapper.find('.js-commit-component')).toBeDefined(); }); }); + + describe('Without auto-stop date', () => { + beforeEach(() => { + factory({ + propsData: { + model: environment, + canReadEnvironment: true, + tableData, + shouldShowAutoStopDate: true, + }, + }); + }); + + it('should not render a date', () => { + expect(findAutoStop().exists()).toBe(false); + }); + + it('should not render the suto-stop button', () => { + expect(wrapper.find(PinComponent).exists()).toBe(false); + }); + }); + + describe('With auto-stop date', () => { + describe('in the future', () => { + const futureDate = new Date(Date.now() + 100000); + beforeEach(() => { + factory({ + propsData: { + model: { + ...environment, + auto_stop_at: futureDate, + }, + canReadEnvironment: true, + tableData, + shouldShowAutoStopDate: true, + }, + }); + }); + + it('renders the date', () => { + expect(findAutoStop().text()).toContain(format(futureDate)); + }); + + it('should render the auto-stop button', () => { + expect(wrapper.find(PinComponent).exists()).toBe(true); + }); + }); + + describe('in the past', () => { + const pastDate = new Date(Date.now() - 100000); + beforeEach(() => { + factory({ + propsData: { + model: { + ...environment, + auto_stop_at: pastDate, + }, + canReadEnvironment: true, + tableData, + shouldShowAutoStopDate: true, + }, + }); + }); + + it('should not render a date', () => { + expect(findAutoStop().exists()).toBe(false); + }); + + it('should not render the suto-stop button', () => { + expect(wrapper.find(PinComponent).exists()).toBe(false); + }); + }); + }); }); describe('With manual actions', () => { diff --git a/spec/frontend/environments/environment_monitoring_spec.js b/spec/frontend/environments/environment_monitoring_spec.js index 8e67f799dc08276d0d6e5e88e1f58ea417aa18fa..d2129bd7b30ae392903be8ddbfda8f581e64538d 100644 --- a/spec/frontend/environments/environment_monitoring_spec.js +++ b/spec/frontend/environments/environment_monitoring_spec.js @@ -9,8 +9,6 @@ describe('Monitoring Component', () => { const createWrapper = () => { wrapper = shallowMount(MonitoringComponent, { - sync: false, - attachToDocument: true, propsData: { monitoringUrl, }, @@ -33,7 +31,7 @@ describe('Monitoring Component', () => { it('should render a link to environment monitoring page', () => { expect(wrapper.attributes('href')).toEqual(monitoringUrl); expect(findIconsByName('chart').length).toBe(1); - expect(wrapper.attributes('data-original-title')).toBe('Monitoring'); + expect(wrapper.attributes('title')).toBe('Monitoring'); expect(wrapper.attributes('aria-label')).toBe('Monitoring'); }); }); diff --git a/spec/frontend/environments/environment_pin_spec.js b/spec/frontend/environments/environment_pin_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..d1d6735fa386bd37bab45d122f6a2f57d1577f2f --- /dev/null +++ b/spec/frontend/environments/environment_pin_spec.js @@ -0,0 +1,46 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlButton } from '@gitlab/ui'; +import Icon from '~/vue_shared/components/icon.vue'; +import eventHub from '~/environments/event_hub'; +import PinComponent from '~/environments/components/environment_pin.vue'; + +describe('Pin Component', () => { + let wrapper; + + const factory = (options = {}) => { + // This destroys any wrappers created before a nested call to factory reassigns it + if (wrapper && wrapper.destroy) { + wrapper.destroy(); + } + wrapper = shallowMount(PinComponent, { + ...options, + }); + }; + + const autoStopUrl = '/root/auto-stop-env-test/-/environments/38/cancel_auto_stop'; + + beforeEach(() => { + factory({ + propsData: { + autoStopUrl, + }, + }); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('should render the component with thumbtack icon', () => { + expect(wrapper.find(Icon).props('name')).toBe('thumbtack'); + }); + + it('should emit onPinClick when clicked', () => { + const eventHubSpy = jest.spyOn(eventHub, '$emit'); + const button = wrapper.find(GlButton); + + button.vm.$emit('click'); + + expect(eventHubSpy).toHaveBeenCalledWith('cancelAutoStop', autoStopUrl); + }); +}); diff --git a/spec/frontend/environments/environment_rollback_spec.js b/spec/frontend/environments/environment_rollback_spec.js index 33e04f5eb29f50edb4b1a6e5cdffc650a85b2abb..fb62a096c3de62310ccaca34dc7ef7f0b35bff24 100644 --- a/spec/frontend/environments/environment_rollback_spec.js +++ b/spec/frontend/environments/environment_rollback_spec.js @@ -13,8 +13,6 @@ describe('Rollback Component', () => { isLastDeployment: true, environment: {}, }, - attachToDocument: true, - sync: false, }); expect(wrapper.element).toHaveSpriteIcon('repeat'); @@ -27,8 +25,6 @@ describe('Rollback Component', () => { isLastDeployment: false, environment: {}, }, - attachToDocument: true, - sync: false, }); expect(wrapper.element).toHaveSpriteIcon('redo'); diff --git a/spec/frontend/environments/environment_stop_spec.js b/spec/frontend/environments/environment_stop_spec.js index ab71472831181b356d6a7bdd45a5b045cb0a3f7c..f971cf56b65e55dfd782a9da38cb3c59e4da8758 100644 --- a/spec/frontend/environments/environment_stop_spec.js +++ b/spec/frontend/environments/environment_stop_spec.js @@ -11,8 +11,6 @@ describe('Stop Component', () => { const createWrapper = () => { wrapper = shallowMount(StopComponent, { - sync: false, - attachToDocument: true, propsData: { environment: {}, }, @@ -29,7 +27,7 @@ describe('Stop Component', () => { it('should render a button to stop the environment', () => { expect(findButton().exists()).toBe(true); - expect(wrapper.attributes('data-original-title')).toEqual('Stop environment'); + expect(wrapper.attributes('title')).toEqual('Stop environment'); }); it('emits requestStopEnvironment in the event hub when button is clicked', () => { diff --git a/spec/frontend/environments/environment_terminal_button_spec.js b/spec/frontend/environments/environment_terminal_button_spec.js index 9aa2b82736c14ad923039f9ba36bd737d0a7a280..007fda2f2cc7dfe8981fc1b3954bb926c1917102 100644 --- a/spec/frontend/environments/environment_terminal_button_spec.js +++ b/spec/frontend/environments/environment_terminal_button_spec.js @@ -7,8 +7,6 @@ describe('Stop Component', () => { const mountWithProps = props => { wrapper = shallowMount(TerminalComponent, { - sync: false, - attachToDocument: true, propsData: props, }); }; @@ -25,7 +23,7 @@ describe('Stop Component', () => { it('should render a link to open a web terminal with the provided path', () => { expect(wrapper.is('a')).toBe(true); - expect(wrapper.attributes('data-original-title')).toBe('Terminal'); + expect(wrapper.attributes('title')).toBe('Terminal'); expect(wrapper.attributes('aria-label')).toBe('Terminal'); expect(wrapper.attributes('href')).toBe(terminalPath); }); diff --git a/spec/frontend/environments/mock_data.js b/spec/frontend/environments/mock_data.js index a014108b898c81fac29de5c39fd203dd623368b8..a2b581578d267b5c919e3928ad1a9e3d3d2351ae 100644 --- a/spec/frontend/environments/mock_data.js +++ b/spec/frontend/environments/mock_data.js @@ -63,6 +63,7 @@ const environment = { log_path: 'root/ci-folders/environments/31/logs', created_at: '2016-11-07T11:11:16.525Z', updated_at: '2016-11-10T15:55:58.778Z', + auto_stop_at: null, }; const folder = { @@ -98,6 +99,10 @@ const tableData = { title: 'Updated', spacing: 'section-10', }, + autoStop: { + title: 'Auto stop in', + spacing: 'section-5', + }, actions: { spacing: 'section-25', }, diff --git a/spec/frontend/error_tracking/components/error_details_spec.js b/spec/frontend/error_tracking/components/error_details_spec.js index 6dc4980aaec33c9b185cf57099ac660d337f76c3..35014b00dd886fab985b71a0f8f688d2a3c1a6dd 100644 --- a/spec/frontend/error_tracking/components/error_details_spec.js +++ b/spec/frontend/error_tracking/components/error_details_spec.js @@ -1,6 +1,6 @@ import { createLocalVue, shallowMount } from '@vue/test-utils'; import Vuex from 'vuex'; -import { GlLoadingIcon, GlLink } from '@gitlab/ui'; +import { GlLoadingIcon, GlLink, GlBadge, GlFormInput } from '@gitlab/ui'; import LoadingButton from '~/vue_shared/components/loading_button.vue'; import Stacktrace from '~/error_tracking/components/stacktrace.vue'; import ErrorDetails from '~/error_tracking/components/error_details.vue'; @@ -13,19 +13,42 @@ describe('ErrorDetails', () => { let wrapper; let actions; let getters; + let mocks; + + const findInput = name => { + const inputs = wrapper.findAll(GlFormInput).filter(c => c.attributes('name') === name); + return inputs.length ? inputs.at(0) : inputs; + }; function mountComponent() { wrapper = shallowMount(ErrorDetails, { stubs: { LoadingButton }, localVue, store, + mocks, propsData: { + issueId: '123', + projectPath: '/root/gitlab-test', + listPath: '/error_tracking', + issueUpdatePath: '/123', issueDetailsPath: '/123/details', issueStackTracePath: '/stacktrace', projectIssuesPath: '/test-project/issues/', csrfToken: 'fakeToken', }, }); + wrapper.setData({ + GQLerror: { + id: 'gid://gitlab/Gitlab::ErrorTracking::DetailedError/129381', + sentryId: 129381, + title: 'Issue title', + externalUrl: 'http://sentry.gitlab.net/gitlab', + firstSeen: '2017-05-26T13:32:48Z', + lastSeen: '2018-05-26T13:32:48Z', + count: 12, + userCount: 2, + }, + }); } beforeEach(() => { @@ -56,6 +79,19 @@ describe('ErrorDetails', () => { }, }, }); + + const query = jest.fn(); + mocks = { + $apollo: { + query, + queries: { + GQLerror: { + loading: true, + stopPolling: jest.fn(), + }, + }, + }, + }; }); afterEach(() => { @@ -77,27 +113,50 @@ describe('ErrorDetails', () => { }); describe('Error details', () => { - it('should show Sentry error details without stacktrace', () => { + beforeEach(() => { store.state.details.loading = false; store.state.details.error.id = 1; + mocks.$apollo.queries.GQLerror.loading = false; mountComponent(); + }); + + it('should show Sentry error details without stacktrace', () => { expect(wrapper.find(GlLink).exists()).toBe(true); expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); expect(wrapper.find(Stacktrace).exists()).toBe(false); + expect(wrapper.find(GlBadge).exists()).toBe(false); + expect(wrapper.findAll('button').length).toBe(3); + }); + + describe('Badges', () => { + it('should show language and error level badges', () => { + store.state.details.error.tags = { level: 'error', logger: 'ruby' }; + mountComponent(); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.findAll(GlBadge).length).toBe(2); + }); + }); + + it('should NOT show the badge if the tag is not present', () => { + store.state.details.error.tags = { level: 'error' }; + mountComponent(); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.findAll(GlBadge).length).toBe(1); + }); + }); }); describe('Stacktrace', () => { it('should show stacktrace', () => { - store.state.details.loading = false; - store.state.details.error.id = 1; store.state.details.loadingStacktrace = false; mountComponent(); - expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); - expect(wrapper.find(Stacktrace).exists()).toBe(true); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); + expect(wrapper.find(Stacktrace).exists()).toBe(true); + }); }); it('should NOT show stacktrace if no entries', () => { - store.state.details.loading = false; store.state.details.loadingStacktrace = false; store.getters = { 'details/sentryUrl': () => 'sentry.io', 'details/stacktrace': () => [] }; mountComponent(); @@ -108,29 +167,19 @@ describe('ErrorDetails', () => { describe('When a user clicks the create issue button', () => { beforeEach(() => { - store.state.details.loading = false; - store.state.details.error = { - id: 129381, - title: 'Issue title', - external_url: 'http://sentry.gitlab.net/gitlab', - first_seen: '2017-05-26T13:32:48Z', - last_seen: '2018-05-26T13:32:48Z', - count: 12, - user_count: 2, - }; mountComponent(); }); it('should send sentry_issue_identifier', () => { - const sentryErrorIdInput = wrapper.find( - 'glforminput-stub[name="issue[sentry_issue_attributes][sentry_issue_identifier]"', + const sentryErrorIdInput = findInput( + 'issue[sentry_issue_attributes][sentry_issue_identifier]', ); expect(sentryErrorIdInput.attributes('value')).toBe('129381'); }); it('should set the form values with title and description', () => { - const csrfTokenInput = wrapper.find('glforminput-stub[name="authenticity_token"]'); - const issueTitleInput = wrapper.find('glforminput-stub[name="issue[title]"]'); + const csrfTokenInput = findInput('authenticity_token'); + const issueTitleInput = findInput('issue[title]'); const issueDescriptionInput = wrapper.find('input[name="issue[description]"]'); expect(csrfTokenInput.attributes('value')).toBe('fakeToken'); expect(issueTitleInput.attributes('value')).toContain(wrapper.vm.issueTitle); @@ -140,7 +189,7 @@ describe('ErrorDetails', () => { it('should submit the form', () => { window.HTMLFormElement.prototype.submit = () => {}; const submitSpy = jest.spyOn(wrapper.vm.$refs.sentryIssueForm, 'submit'); - wrapper.find('button').trigger('click'); + wrapper.find('[data-qa-selector="create_issue_button"]').trigger('click'); expect(submitSpy).toHaveBeenCalled(); submitSpy.mockRestore(); }); @@ -150,6 +199,7 @@ describe('ErrorDetails', () => { const gitlabIssue = 'https://gitlab.example.com/issues/1'; const findGitLabLink = () => wrapper.find(`[href="${gitlabIssue}"]`); const findCreateIssueButton = () => wrapper.find('[data-qa-selector="create_issue_button"]'); + const findViewIssueButton = () => wrapper.find('[data-qa-selector="view_issue_button"]'); describe('is present', () => { beforeEach(() => { @@ -161,6 +211,10 @@ describe('ErrorDetails', () => { mountComponent(); }); + it('should display the View issue button', () => { + expect(findViewIssueButton().exists()).toBe(true); + }); + it('should display the issue link', () => { expect(findGitLabLink().exists()).toBe(true); }); @@ -180,13 +234,50 @@ describe('ErrorDetails', () => { mountComponent(); }); + it('should not display the View issue button', () => { + expect(findViewIssueButton().exists()).toBe(false); + }); + it('should not display an issue link', () => { expect(findGitLabLink().exists()).toBe(false); }); + it('should display the create issue button', () => { expect(findCreateIssueButton().exists()).toBe(true); }); }); }); + + describe('GitLab commit link', () => { + const gitlabCommit = '7975be0116940bf2ad4321f79d02a55c5f7779aa'; + const gitlabCommitPath = + '/gitlab-org/gitlab-test/commit/7975be0116940bf2ad4321f79d02a55c5f7779aa'; + const findGitLabCommitLink = () => wrapper.find(`[href$="${gitlabCommitPath}"]`); + + it('should display a link', () => { + mocks.$apollo.queries.GQLerror.loading = false; + wrapper.setData({ + GQLerror: { + gitlabCommit, + gitlabCommitPath, + }, + }); + return wrapper.vm.$nextTick().then(() => { + expect(findGitLabCommitLink().exists()).toBe(true); + }); + }); + + it('should not display a link', () => { + mocks.$apollo.queries.GQLerror.loading = false; + wrapper.setData({ + GQLerror: { + gitlabCommit: null, + }, + }); + return wrapper.vm.$nextTick().then(() => { + expect(findGitLabCommitLink().exists()).toBe(false); + }); + }); + }); }); }); diff --git a/spec/frontend/error_tracking/components/error_tracking_list_spec.js b/spec/frontend/error_tracking/components/error_tracking_list_spec.js index 581581405b6fab0c0cf1e5645d8db334db4e6d5a..310cd676ca1e2555925e667c6b61512495a7814c 100644 --- a/spec/frontend/error_tracking/components/error_tracking_list_spec.js +++ b/spec/frontend/error_tracking/components/error_tracking_list_spec.js @@ -1,15 +1,7 @@ -import { createLocalVue, shallowMount } from '@vue/test-utils'; +import { createLocalVue, mount } from '@vue/test-utils'; import Vuex from 'vuex'; -import { - GlEmptyState, - GlLoadingIcon, - GlTable, - GlLink, - GlFormInput, - GlDropdown, - GlDropdownItem, - GlPagination, -} from '@gitlab/ui'; +import { GlEmptyState, GlLoadingIcon, GlFormInput, GlPagination } from '@gitlab/ui'; +import stubChildren from 'helpers/stub_children'; import ErrorTrackingList from '~/error_tracking/components/error_tracking_list.vue'; import errorsList from './list_mock.json'; @@ -32,27 +24,24 @@ describe('ErrorTrackingList', () => { function mountComponent({ errorTrackingEnabled = true, userCanEnableErrorTracking = true, - sync = true, - stubs = { - 'gl-link': GlLink, - 'gl-table': GlTable, - 'gl-pagination': GlPagination, - 'gl-dropdown': GlDropdown, - 'gl-dropdown-item': GlDropdownItem, - }, + stubs = {}, } = {}) { - wrapper = shallowMount(ErrorTrackingList, { + wrapper = mount(ErrorTrackingList, { localVue, store, - sync, propsData: { indexPath: '/path', + listPath: '/error_tracking', + projectPath: 'project/test', enableErrorTrackingLink: '/link', userCanEnableErrorTracking, errorTrackingEnabled, illustrationPath: 'illustration/path', }, - stubs, + stubs: { + ...stubChildren(ErrorTrackingList), + ...stubs, + }, data() { return { errorSearchQuery: 'search' }; }, @@ -71,6 +60,8 @@ describe('ErrorTrackingList', () => { setEndpoint: jest.fn(), searchByQuery: jest.fn(), sortByField: jest.fn(), + fetchPaginatedResults: jest.fn(), + updateStatus: jest.fn(), }; const state = { @@ -121,7 +112,14 @@ describe('ErrorTrackingList', () => { beforeEach(() => { store.state.list.loading = false; store.state.list.errors = errorsList; - mountComponent(); + mountComponent({ + stubs: { + GlTable: false, + GlDropdown: false, + GlDropdownItem: false, + GlLink: false, + }, + }); }); it('shows table', () => { @@ -144,6 +142,18 @@ describe('ErrorTrackingList', () => { }); }); + it('each error in the list should have an ignore button', () => { + findErrorListRows().wrappers.forEach(row => { + expect(row.contains('glicon-stub[name="eye-slash"]')).toBe(true); + }); + }); + + it('each error in the list should have a resolve button', () => { + findErrorListRows().wrappers.forEach(row => { + expect(row.contains('glicon-stub[name="check-circle"]')).toBe(true); + }); + }); + describe('filtering', () => { const findSearchBox = () => wrapper.find(GlFormInput); @@ -172,7 +182,13 @@ describe('ErrorTrackingList', () => { store.state.list.loading = false; store.state.list.errors = []; - mountComponent(); + mountComponent({ + stubs: { + GlTable: false, + GlDropdown: false, + GlDropdownItem: false, + }, + }); }); it('shows empty table', () => { @@ -186,7 +202,7 @@ describe('ErrorTrackingList', () => { }); it('restarts polling', () => { - findRefreshLink().trigger('click'); + findRefreshLink().vm.$emit('click'); expect(actions.restartPolling).toHaveBeenCalled(); }); }); @@ -204,14 +220,70 @@ describe('ErrorTrackingList', () => { }); }); + describe('When the ignore button on an error is clicked', () => { + beforeEach(() => { + store.state.list.loading = false; + store.state.list.errors = errorsList; + + mountComponent({ + stubs: { + GlTable: false, + GlLink: false, + GlButton: false, + }, + }); + }); + + it('sends the "ignored" status and error ID', () => { + wrapper.find({ ref: 'ignoreError' }).trigger('click'); + expect(actions.updateStatus).toHaveBeenCalledWith( + expect.anything(), + { + endpoint: '/project/test/-/error_tracking/3.json', + redirectUrl: '/error_tracking', + status: 'ignored', + }, + undefined, + ); + }); + }); + + describe('When the resolve button on an error is clicked', () => { + beforeEach(() => { + store.state.list.loading = false; + store.state.list.errors = errorsList; + + mountComponent({ + stubs: { + GlTable: false, + GlLink: false, + GlButton: false, + }, + }); + }); + + it('sends "resolved" status and error ID', () => { + wrapper.find({ ref: 'resolveError' }).trigger('click'); + expect(actions.updateStatus).toHaveBeenCalledWith( + expect.anything(), + { + endpoint: '/project/test/-/error_tracking/3.json', + redirectUrl: '/error_tracking', + status: 'resolved', + }, + undefined, + ); + }); + }); + describe('When error tracking is disabled and user is not allowed to enable it', () => { beforeEach(() => { mountComponent({ errorTrackingEnabled: false, userCanEnableErrorTracking: false, stubs: { - 'gl-link': GlLink, - 'gl-empty-state': GlEmptyState, + GlLink: false, + GlEmptyState: false, }, }); }); @@ -225,7 +297,12 @@ describe('ErrorTrackingList', () => { describe('recent searches', () => { beforeEach(() => { - mountComponent(); + mountComponent({ + stubs: { + GlDropdown: false, + GlDropdownItem: false, + }, + }); }); it('shows empty message', () => { @@ -237,11 +314,12 @@ describe('ErrorTrackingList', () => { it('shows items', () => { store.state.list.recentSearches = ['great', 'search']; - const dropdownItems = wrapper.findAll('.filtered-search-box li'); - - expect(dropdownItems.length).toBe(3); - expect(dropdownItems.at(0).text()).toBe('great'); - expect(dropdownItems.at(1).text()).toBe('search'); + return wrapper.vm.$nextTick().then(() => { + const dropdownItems = wrapper.findAll('.filtered-search-box li'); + expect(dropdownItems.length).toBe(3); + expect(dropdownItems.at(0).text()).toBe('great'); + expect(dropdownItems.at(1).text()).toBe('search'); + }); }); describe('clear', () => { @@ -256,22 +334,27 @@ describe('ErrorTrackingList', () => { it('is visible when list has items', () => { store.state.list.recentSearches = ['some', 'searches']; - expect(clearRecentButton().exists()).toBe(true); - expect(clearRecentButton().text()).toBe('Clear recent searches'); + return wrapper.vm.$nextTick().then(() => { + expect(clearRecentButton().exists()).toBe(true); + expect(clearRecentButton().text()).toBe('Clear recent searches'); + }); }); it('clears items on click', () => { store.state.list.recentSearches = ['some', 'searches']; - clearRecentButton().vm.$emit('click'); + return wrapper.vm.$nextTick().then(() => { + clearRecentButton().vm.$emit('click'); - expect(actions.clearRecentSearches).toHaveBeenCalledTimes(1); + expect(actions.clearRecentSearches).toHaveBeenCalledTimes(1); + }); }); }); }); describe('When pagination is not required', () => { beforeEach(() => { + store.state.list.loading = false; store.state.list.pagination = {}; mountComponent(); }); @@ -284,7 +367,12 @@ describe('ErrorTrackingList', () => { describe('When pagination is required', () => { describe('and the user is on the first page', () => { beforeEach(() => { - mountComponent({ sync: false }); + store.state.list.loading = false; + mountComponent({ + stubs: { + GlPagination: false, + }, + }); }); it('shows a disabled Prev button', () => { @@ -295,17 +383,24 @@ describe('ErrorTrackingList', () => { describe('and the user is not on the first page', () => { describe('and the previous button is clicked', () => { beforeEach(() => { - mountComponent({ sync: false }); + store.state.list.loading = false; + mountComponent({ + stubs: { + GlTable: false, + GlPagination: false, + }, + }); wrapper.setData({ pageValue: 2 }); + return wrapper.vm.$nextTick(); }); it('fetches the previous page of results', () => { expect(wrapper.find('.prev-page-item').attributes('aria-disabled')).toBe(undefined); wrapper.vm.goToPrevPage(); - expect(actions.startPolling).toHaveBeenCalledTimes(2); - expect(actions.startPolling).toHaveBeenLastCalledWith( + expect(actions.fetchPaginatedResults).toHaveBeenCalled(); + expect(actions.fetchPaginatedResults).toHaveBeenLastCalledWith( expect.anything(), - '/path?cursor=previousCursor', + 'previousCursor', undefined, ); }); @@ -313,17 +408,18 @@ describe('ErrorTrackingList', () => { describe('and the next page button is clicked', () => { beforeEach(() => { - mountComponent({ sync: false }); + store.state.list.loading = false; + mountComponent(); }); it('fetches the next page of results', () => { window.scrollTo = jest.fn(); findPagination().vm.$emit('input', 2); expect(window.scrollTo).toHaveBeenCalledWith(0, 0); - expect(actions.startPolling).toHaveBeenCalledTimes(2); - expect(actions.startPolling).toHaveBeenLastCalledWith( + expect(actions.fetchPaginatedResults).toHaveBeenCalled(); + expect(actions.fetchPaginatedResults).toHaveBeenLastCalledWith( expect.anything(), - '/path?cursor=nextCursor', + 'nextCursor', undefined, ); }); diff --git a/spec/frontend/error_tracking/components/stacktrace_entry_spec.js b/spec/frontend/error_tracking/components/stacktrace_entry_spec.js index 942585d5370f742e166c88c9b0e633ff76b6269c..2a4e826b4ab754630c65f98e23cce8cdc07b3952 100644 --- a/spec/frontend/error_tracking/components/stacktrace_entry_spec.js +++ b/spec/frontend/error_tracking/components/stacktrace_entry_spec.js @@ -46,8 +46,8 @@ describe('Stacktrace Entry', () => { expect(wrapper.findAll('.line_content.old').length).toBe(1); }); - describe('no code block', () => { - const findFileHeaderContent = () => wrapper.find('.file-header-content').html(); + describe('entry caption', () => { + const findFileHeaderContent = () => wrapper.find('.file-header-content').text(); it('should hide collapse icon and render error fn name and error line when there is no code block', () => { const extraInfo = { errorLine: 34, errorFn: 'errorFn', errorColumn: 77 }; diff --git a/spec/frontend/error_tracking/store/actions_spec.js b/spec/frontend/error_tracking/store/actions_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..8bc53d94345cd242b3f3533f329eab5d6fca8d31 --- /dev/null +++ b/spec/frontend/error_tracking/store/actions_spec.js @@ -0,0 +1,78 @@ +import MockAdapter from 'axios-mock-adapter'; +import testAction from 'helpers/vuex_action_helper'; +import axios from '~/lib/utils/axios_utils'; +import createFlash from '~/flash'; +import * as actions from '~/error_tracking/store/actions'; +import * as types from '~/error_tracking/store/mutation_types'; +import { visitUrl } from '~/lib/utils/url_utility'; + +jest.mock('~/flash.js'); +jest.mock('~/lib/utils/url_utility'); + +let mock; + +describe('Sentry common store actions', () => { + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + createFlash.mockClear(); + }); + + describe('updateStatus', () => { + const endpoint = '123/stacktrace'; + const redirectUrl = '/list'; + const status = 'resolved'; + + it('should handle successful status update', done => { + mock.onPut().reply(200, {}); + testAction( + actions.updateStatus, + { endpoint, redirectUrl, status }, + {}, + [ + { + payload: true, + type: types.SET_UPDATING_RESOLVE_STATUS, + }, + { + payload: false, + type: 'SET_UPDATING_RESOLVE_STATUS', + }, + ], + [], + () => { + done(); + expect(visitUrl).toHaveBeenCalledWith(redirectUrl); + }, + ); + }); + + it('should handle unsuccessful status update', done => { + mock.onPut().reply(400, {}); + testAction( + actions.updateStatus, + { endpoint, redirectUrl, status }, + {}, + [ + { + payload: true, + type: types.SET_UPDATING_RESOLVE_STATUS, + }, + { + payload: false, + type: types.SET_UPDATING_RESOLVE_STATUS, + }, + ], + [], + () => { + expect(visitUrl).not.toHaveBeenCalled(); + expect(createFlash).toHaveBeenCalledTimes(1); + done(); + }, + ); + }); + }); +}); diff --git a/spec/frontend/error_tracking/store/details/actions_spec.js b/spec/frontend/error_tracking/store/details/actions_spec.js index 0866f76aeefb0ff8004da13522841ab9f90aad6e..129760bb7057cbd2bd7f770d384a7a8d0c38c940 100644 --- a/spec/frontend/error_tracking/store/details/actions_spec.js +++ b/spec/frontend/error_tracking/store/details/actions_spec.js @@ -6,6 +6,8 @@ import * as actions from '~/error_tracking/store/details/actions'; import * as types from '~/error_tracking/store/details/mutation_types'; jest.mock('~/flash.js'); +jest.mock('~/lib/utils/url_utility'); + let mock; describe('Sentry error details store actions', () => { diff --git a/spec/frontend/error_tracking/store/list/actions_spec.js b/spec/frontend/error_tracking/store/list/actions_spec.js index 7906738f5b0745e5e7c2d6da3ff4f69532164b1c..54fdde88818b4a1cbaaa20fa303099cf6755fb43 100644 --- a/spec/frontend/error_tracking/store/list/actions_spec.js +++ b/spec/frontend/error_tracking/store/list/actions_spec.js @@ -79,6 +79,7 @@ describe('error tracking actions', () => { query, {}, [ + { type: types.SET_CURSOR, payload: null }, { type: types.SET_SEARCH_QUERY, payload: query }, { type: types.ADD_RECENT_SEARCH, payload: query }, ], @@ -93,15 +94,15 @@ describe('error tracking actions', () => { testAction( actions.sortByField, - { field }, + field, {}, - [{ type: types.SET_SORT_FIELD, payload: { field } }], + [{ type: types.SET_CURSOR, payload: null }, { type: types.SET_SORT_FIELD, payload: field }], [{ type: 'stopPolling' }, { type: 'startPolling' }], ); }); }); - describe('setEnpoint', () => { + describe('setEndpoint', () => { it('should set search endpoint', () => { const endpoint = 'https://sentry.io'; @@ -114,4 +115,17 @@ describe('error tracking actions', () => { ); }); }); + + describe('fetchPaginatedResults', () => { + it('should start polling the selected page cursor', () => { + const cursor = '1576637570000:1:1'; + testAction( + actions.fetchPaginatedResults, + cursor, + {}, + [{ type: types.SET_CURSOR, payload: cursor }], + [{ type: 'stopPolling' }, { type: 'startPolling' }], + ); + }); + }); }); diff --git a/spec/frontend/error_tracking_settings/components/app_spec.js b/spec/frontend/error_tracking_settings/components/app_spec.js index 0b86aad5b3ec8c46a64c81643e471bc78701dc23..5c3efa245513cf5cd4b133b5e8cf4d5ff5ada8cb 100644 --- a/spec/frontend/error_tracking_settings/components/app_spec.js +++ b/spec/frontend/error_tracking_settings/components/app_spec.js @@ -57,7 +57,9 @@ describe('error tracking settings app', () => { it('disables the button when saving', () => { store.state.settingsLoading = true; - expect(wrapper.find('.js-error-tracking-button').attributes('disabled')).toBeTruthy(); + return wrapper.vm.$nextTick(() => { + expect(wrapper.find('.js-error-tracking-button').attributes('disabled')).toBeTruthy(); + }); }); }); }); diff --git a/spec/frontend/error_tracking_settings/components/project_dropdown_spec.js b/spec/frontend/error_tracking_settings/components/project_dropdown_spec.js index 8e5dbe28452ce637b509cecf5c52d565b8335ed0..3ce105f27e48bd2b22a69d8d63bf44ad0ac5ba37 100644 --- a/spec/frontend/error_tracking_settings/components/project_dropdown_spec.js +++ b/spec/frontend/error_tracking_settings/components/project_dropdown_spec.js @@ -66,6 +66,8 @@ describe('error tracking settings project dropdown', () => { describe('populated project list', () => { beforeEach(() => { wrapper.setProps({ projects: _.clone(projectList), hasProjects: true }); + + return wrapper.vm.$nextTick(); }); it('renders the dropdown', () => { @@ -84,6 +86,7 @@ describe('error tracking settings project dropdown', () => { beforeEach(() => { wrapper.setProps({ projects: _.clone(projectList), selectedProject, hasProjects: true }); + return wrapper.vm.$nextTick(); }); it('does not show helper text', () => { @@ -99,6 +102,7 @@ describe('error tracking settings project dropdown', () => { selectedProject: staleProject, isProjectInvalid: true, }); + return wrapper.vm.$nextTick(); }); it('displays a error', () => { diff --git a/spec/frontend/error_tracking_settings/store/actions_spec.js b/spec/frontend/error_tracking_settings/store/actions_spec.js index e12c4e20f5858a213e0ed165ad067ea93dfc85a4..b076e6ecd31139c5891dcb2b0e10caf3a6b0bdee 100644 --- a/spec/frontend/error_tracking_settings/store/actions_spec.js +++ b/spec/frontend/error_tracking_settings/store/actions_spec.js @@ -28,7 +28,7 @@ describe('error tracking settings actions', () => { }); it('should request and transform the project list', done => { - mock.onPost(TEST_HOST).reply(() => [200, { projects: projectList }]); + mock.onGet(TEST_HOST).reply(() => [200, { projects: projectList }]); testAction( actions.fetchProjects, null, @@ -42,14 +42,14 @@ describe('error tracking settings actions', () => { }, ], () => { - expect(mock.history.post.length).toBe(1); + expect(mock.history.get.length).toBe(1); done(); }, ); }); it('should handle a server error', done => { - mock.onPost(`${TEST_HOST}.json`).reply(() => [400]); + mock.onGet(`${TEST_HOST}.json`).reply(() => [400]); testAction( actions.fetchProjects, null, @@ -62,7 +62,7 @@ describe('error tracking settings actions', () => { }, ], () => { - expect(mock.history.post.length).toBe(1); + expect(mock.history.get.length).toBe(1); done(); }, ); diff --git a/spec/javascripts/feature_highlight/feature_highlight_options_spec.js b/spec/frontend/feature_highlight/feature_highlight_options_spec.js similarity index 51% rename from spec/javascripts/feature_highlight/feature_highlight_options_spec.js rename to spec/frontend/feature_highlight/feature_highlight_options_spec.js index 7f9425d8abea29fe33a7485ba787d684127ede1b..8b75c46fd4c345c1718e68b86268662116eb1ae2 100644 --- a/spec/javascripts/feature_highlight/feature_highlight_options_spec.js +++ b/spec/frontend/feature_highlight/feature_highlight_options_spec.js @@ -1,28 +1,34 @@ +import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import domContentLoaded from '~/feature_highlight/feature_highlight_options'; -import bp from '~/breakpoints'; describe('feature highlight options', () => { describe('domContentLoaded', () => { it('should not call highlightFeatures when breakpoint is xs', () => { - spyOn(bp, 'getBreakpointSize').and.returnValue('xs'); + jest.spyOn(bp, 'getBreakpointSize').mockReturnValue('xs'); expect(domContentLoaded()).toBe(false); }); it('should not call highlightFeatures when breakpoint is sm', () => { - spyOn(bp, 'getBreakpointSize').and.returnValue('sm'); + jest.spyOn(bp, 'getBreakpointSize').mockReturnValue('sm'); expect(domContentLoaded()).toBe(false); }); it('should not call highlightFeatures when breakpoint is md', () => { - spyOn(bp, 'getBreakpointSize').and.returnValue('md'); + jest.spyOn(bp, 'getBreakpointSize').mockReturnValue('md'); expect(domContentLoaded()).toBe(false); }); - it('should call highlightFeatures when breakpoint is lg', () => { - spyOn(bp, 'getBreakpointSize').and.returnValue('lg'); + it('should not call highlightFeatures when breakpoint is not xl', () => { + jest.spyOn(bp, 'getBreakpointSize').mockReturnValue('lg'); + + expect(domContentLoaded()).toBe(false); + }); + + it('should call highlightFeatures when breakpoint is xl', () => { + jest.spyOn(bp, 'getBreakpointSize').mockReturnValue('xl'); expect(domContentLoaded()).toBe(true); }); diff --git a/spec/javascripts/filtered_search/components/recent_searches_dropdown_content_spec.js b/spec/frontend/filtered_search/components/recent_searches_dropdown_content_spec.js similarity index 97% rename from spec/javascripts/filtered_search/components/recent_searches_dropdown_content_spec.js rename to spec/frontend/filtered_search/components/recent_searches_dropdown_content_spec.js index d1742dcedfa4ed9d15a1ea03660394bf0f431e88..2543fb8768bf5d9c244921f42e907640ab1a3fb9 100644 --- a/spec/javascripts/filtered_search/components/recent_searches_dropdown_content_spec.js +++ b/spec/frontend/filtered_search/components/recent_searches_dropdown_content_spec.js @@ -158,7 +158,7 @@ describe('RecentSearchesDropdownContent', () => { let onRecentSearchesItemSelectedSpy; beforeEach(() => { - onRecentSearchesItemSelectedSpy = jasmine.createSpy('spy'); + onRecentSearchesItemSelectedSpy = jest.fn(); eventHub.$on('recentSearchesItemSelected', onRecentSearchesItemSelectedSpy); vm = createComponent(propsDataWithItems); @@ -180,7 +180,7 @@ describe('RecentSearchesDropdownContent', () => { let onRequestClearRecentSearchesSpy; beforeEach(() => { - onRequestClearRecentSearchesSpy = jasmine.createSpy('spy'); + onRequestClearRecentSearchesSpy = jest.fn(); eventHub.$on('requestClearRecentSearches', onRequestClearRecentSearchesSpy); vm = createComponent(propsDataWithItems); diff --git a/spec/javascripts/filtered_search/dropdown_user_spec.js b/spec/frontend/filtered_search/dropdown_user_spec.js similarity index 80% rename from spec/javascripts/filtered_search/dropdown_user_spec.js rename to spec/frontend/filtered_search/dropdown_user_spec.js index f764800fff0bf2824882cb48eba84dc6e9ee8030..8eef10290bfe04a30ddb80e420434f5f8400523c 100644 --- a/spec/javascripts/filtered_search/dropdown_user_spec.js +++ b/spec/frontend/filtered_search/dropdown_user_spec.js @@ -8,10 +8,10 @@ describe('Dropdown User', () => { let dropdownUser; beforeEach(() => { - spyOn(DropdownUser.prototype, 'bindEvents').and.callFake(() => {}); - spyOn(DropdownUser.prototype, 'getProjectId').and.callFake(() => {}); - spyOn(DropdownUser.prototype, 'getGroupId').and.callFake(() => {}); - spyOn(DropdownUtils, 'getSearchInput').and.callFake(() => {}); + jest.spyOn(DropdownUser.prototype, 'bindEvents').mockImplementation(() => {}); + jest.spyOn(DropdownUser.prototype, 'getProjectId').mockImplementation(() => {}); + jest.spyOn(DropdownUser.prototype, 'getGroupId').mockImplementation(() => {}); + jest.spyOn(DropdownUtils, 'getSearchInput').mockImplementation(() => {}); dropdownUser = new DropdownUser({ tokenKeys: IssuableFilteredTokenKeys, @@ -19,7 +19,7 @@ describe('Dropdown User', () => { }); it('should not return the double quote found in value', () => { - spyOn(FilteredSearchTokenizer, 'processTokens').and.returnValue({ + jest.spyOn(FilteredSearchTokenizer, 'processTokens').mockReturnValue({ lastToken: '"johnny appleseed', }); @@ -27,7 +27,7 @@ describe('Dropdown User', () => { }); it('should not return the single quote found in value', () => { - spyOn(FilteredSearchTokenizer, 'processTokens').and.returnValue({ + jest.spyOn(FilteredSearchTokenizer, 'processTokens').mockReturnValue({ lastToken: "'larry boy", }); @@ -37,9 +37,9 @@ describe('Dropdown User', () => { describe("config AjaxFilter's endpoint", () => { beforeEach(() => { - spyOn(DropdownUser.prototype, 'bindEvents').and.callFake(() => {}); - spyOn(DropdownUser.prototype, 'getProjectId').and.callFake(() => {}); - spyOn(DropdownUser.prototype, 'getGroupId').and.callFake(() => {}); + jest.spyOn(DropdownUser.prototype, 'bindEvents').mockImplementation(() => {}); + jest.spyOn(DropdownUser.prototype, 'getProjectId').mockImplementation(() => {}); + jest.spyOn(DropdownUser.prototype, 'getGroupId').mockImplementation(() => {}); }); it('should return endpoint', () => { diff --git a/spec/frontend/filtered_search/filtered_search_token_keys_spec.js b/spec/frontend/filtered_search/filtered_search_token_keys_spec.js index d1fea18dea8e4394b976a0c5dfe2708c0735c428..f24d2b118c2b79734934aa492c6aa051d6c648c9 100644 --- a/spec/frontend/filtered_search/filtered_search_token_keys_spec.js +++ b/spec/frontend/filtered_search/filtered_search_token_keys_spec.js @@ -124,6 +124,7 @@ describe('Filtered Search Token Keys', () => { const condition = new FilteredSearchTokenKeys([], [], conditions).searchByConditionKeyValue( null, null, + null, ); expect(condition).toBeNull(); @@ -132,6 +133,7 @@ describe('Filtered Search Token Keys', () => { it('should return condition when found by tokenKey and value', () => { const result = new FilteredSearchTokenKeys([], [], conditions).searchByConditionKeyValue( conditions[0].tokenKey, + conditions[0].operator, conditions[0].value, ); diff --git a/spec/frontend/fixtures/issues.rb b/spec/frontend/fixtures/issues.rb index 7e52499086337b60a3a527d634b156d82a2d1e1f..9a194e5ca84592302ce5ddd9aa7e7182b2c4742c 100644 --- a/spec/frontend/fixtures/issues.rb +++ b/spec/frontend/fixtures/issues.rb @@ -23,6 +23,15 @@ describe Projects::IssuesController, '(JavaScript fixtures)', type: :controller remove_repository(project) end + it 'issues/new-issue.html' do + get :new, params: { + namespace_id: project.namespace.to_param, + project_id: project + } + + expect(response).to be_successful + end + it 'issues/open-issue.html' do render_issue(create(:issue, project: project)) end diff --git a/spec/frontend/fixtures/static/mock-video.mp4 b/spec/frontend/fixtures/static/mock-video.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..1fc478842f51e7519866f474a02ad605235bc6a6 Binary files /dev/null and b/spec/frontend/fixtures/static/mock-video.mp4 differ diff --git a/spec/javascripts/frequent_items/components/frequent_items_search_input_spec.js b/spec/frontend/frequent_items/components/frequent_items_search_input_spec.js similarity index 80% rename from spec/javascripts/frequent_items/components/frequent_items_search_input_spec.js rename to spec/frontend/frequent_items/components/frequent_items_search_input_spec.js index be11af8428fadeb5db30e273d75d906a5789fbce..204bbfb9c2f7d07b724fd16d088b10c0fae73596 100644 --- a/spec/javascripts/frequent_items/components/frequent_items_search_input_spec.js +++ b/spec/frontend/frequent_items/components/frequent_items_search_input_spec.js @@ -1,14 +1,10 @@ -import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; import searchComponent from '~/frequent_items/components/frequent_items_search_input.vue'; import eventHub from '~/frequent_items/event_hub'; -const localVue = createLocalVue(); - const createComponent = (namespace = 'projects') => - shallowMount(localVue.extend(searchComponent), { + shallowMount(searchComponent, { propsData: { namespace }, - localVue, - sync: false, }); describe('FrequentItemsSearchInputComponent', () => { @@ -28,7 +24,7 @@ describe('FrequentItemsSearchInputComponent', () => { describe('methods', () => { describe('setFocus', () => { it('should set focus to search input', () => { - spyOn(vm.$refs.search, 'focus'); + jest.spyOn(vm.$refs.search, 'focus').mockImplementation(() => {}); vm.setFocus(); @@ -39,13 +35,13 @@ describe('FrequentItemsSearchInputComponent', () => { describe('mounted', () => { it('should listen `dropdownOpen` event', done => { - spyOn(eventHub, '$on'); + jest.spyOn(eventHub, '$on').mockImplementation(() => {}); const vmX = createComponent().vm; - localVue.nextTick(() => { + vmX.$nextTick(() => { expect(eventHub.$on).toHaveBeenCalledWith( `${vmX.namespace}-dropdownOpen`, - jasmine.any(Function), + expect.any(Function), ); done(); }); @@ -55,15 +51,15 @@ describe('FrequentItemsSearchInputComponent', () => { describe('beforeDestroy', () => { it('should unbind event listeners on eventHub', done => { const vmX = createComponent().vm; - spyOn(eventHub, '$off'); + jest.spyOn(eventHub, '$off').mockImplementation(() => {}); vmX.$mount(); vmX.$destroy(); - localVue.nextTick(() => { + vmX.$nextTick(() => { expect(eventHub.$off).toHaveBeenCalledWith( `${vmX.namespace}-dropdownOpen`, - jasmine.any(Function), + expect.any(Function), ); done(); }); diff --git a/spec/javascripts/gl_field_errors_spec.js b/spec/frontend/gl_field_errors_spec.js similarity index 69% rename from spec/javascripts/gl_field_errors_spec.js rename to spec/frontend/gl_field_errors_spec.js index 294f219d6fec3c073c256681f9ddc88c62e074d9..4653f519f65daf4461d9f7e6e9bb6ac882c07e84 100644 --- a/spec/javascripts/gl_field_errors_spec.js +++ b/spec/frontend/gl_field_errors_spec.js @@ -3,83 +3,89 @@ import $ from 'jquery'; import GlFieldErrors from '~/gl_field_errors'; -describe('GL Style Field Errors', function() { +describe('GL Style Field Errors', () => { + let testContext; + + beforeEach(() => { + testContext = {}; + }); + preloadFixtures('static/gl_field_errors.html'); - beforeEach(function() { + beforeEach(() => { loadFixtures('static/gl_field_errors.html'); const $form = $('form.gl-show-field-errors'); - this.$form = $form; - this.fieldErrors = new GlFieldErrors($form); + testContext.$form = $form; + testContext.fieldErrors = new GlFieldErrors($form); }); - it('should select the correct input elements', function() { - expect(this.$form).toBeDefined(); - expect(this.$form.length).toBe(1); - expect(this.fieldErrors).toBeDefined(); - const { inputs } = this.fieldErrors.state; + it('should select the correct input elements', () => { + expect(testContext.$form).toBeDefined(); + expect(testContext.$form.length).toBe(1); + expect(testContext.fieldErrors).toBeDefined(); + const { inputs } = testContext.fieldErrors.state; expect(inputs.length).toBe(4); }); - it('should ignore elements with custom error handling', function() { + it('should ignore elements with custom error handling', () => { const customErrorFlag = 'gl-field-error-ignore'; const customErrorElem = $(`.${customErrorFlag}`); expect(customErrorElem.length).toBe(1); - const customErrors = this.fieldErrors.state.inputs.filter(input => { + const customErrors = testContext.fieldErrors.state.inputs.filter(input => { return input.inputElement.hasClass(customErrorFlag); }); expect(customErrors.length).toBe(0); }); - it('should not show any errors before submit attempt', function() { - this.$form + it('should not show any errors before submit attempt', () => { + testContext.$form .find('.email') .val('not-a-valid-email') .keyup(); - this.$form + testContext.$form .find('.text-required') .val('') .keyup(); - this.$form + testContext.$form .find('.alphanumberic') .val('?---*') .keyup(); - const errorsShown = this.$form.find('.gl-field-error-outline'); + const errorsShown = testContext.$form.find('.gl-field-error-outline'); expect(errorsShown.length).toBe(0); }); - it('should show errors when input valid is submitted', function() { - this.$form + it('should show errors when input valid is submitted', () => { + testContext.$form .find('.email') .val('not-a-valid-email') .keyup(); - this.$form + testContext.$form .find('.text-required') .val('') .keyup(); - this.$form + testContext.$form .find('.alphanumberic') .val('?---*') .keyup(); - this.$form.submit(); + testContext.$form.submit(); - const errorsShown = this.$form.find('.gl-field-error-outline'); + const errorsShown = testContext.$form.find('.gl-field-error-outline'); expect(errorsShown.length).toBe(4); }); - it('should properly track validity state on input after invalid submission attempt', function() { - this.$form.submit(); + it('should properly track validity state on input after invalid submission attempt', () => { + testContext.$form.submit(); - const emailInputModel = this.fieldErrors.state.inputs[1]; + const emailInputModel = testContext.fieldErrors.state.inputs[1]; const fieldState = emailInputModel.state; const emailInputElement = emailInputModel.inputElement; @@ -124,9 +130,9 @@ describe('GL Style Field Errors', function() { expect(fieldState.valid).toBe(true); }); - it('should properly infer error messages', function() { - this.$form.submit(); - const trackedInputs = this.fieldErrors.state.inputs; + it('should properly infer error messages', () => { + testContext.$form.submit(); + const trackedInputs = testContext.fieldErrors.state.inputs; const inputHasTitle = trackedInputs[1]; const hasTitleErrorElem = inputHasTitle.inputElement.siblings('.gl-field-error'); const inputNoTitle = trackedInputs[2]; diff --git a/spec/javascripts/gpg_badges_spec.js b/spec/frontend/gpg_badges_spec.js similarity index 95% rename from spec/javascripts/gpg_badges_spec.js rename to spec/frontend/gpg_badges_spec.js index 4731484e02d3e6922624748c30154b5a9d30af6a..809cc5c88e2035a3f034b87a5a7108ff248115ee 100644 --- a/spec/javascripts/gpg_badges_spec.js +++ b/spec/frontend/gpg_badges_spec.js @@ -38,7 +38,7 @@ describe('GpgBadges', () => { it('does not make a request if there is no container element', done => { setFixtures(''); - spyOn(axios, 'get'); + jest.spyOn(axios, 'get').mockImplementation(() => {}); GpgBadges.fetch() .then(() => { @@ -50,7 +50,7 @@ describe('GpgBadges', () => { it('throws an error if the endpoint is missing', done => { setFixtures('<div class="js-signature-container"></div>'); - spyOn(axios, 'get'); + jest.spyOn(axios, 'get').mockImplementation(() => {}); GpgBadges.fetch() .then(() => done.fail('Expected error to be thrown')) diff --git a/spec/frontend/grafana_integration/components/__snapshots__/grafana_integration_spec.js.snap b/spec/frontend/grafana_integration/components/__snapshots__/grafana_integration_spec.js.snap index 69ad71a1efb4ef084408bb48e662a3bffe646985..5c784c8000fe93364022d0c7dc1194e2a3d16aeb 100644 --- a/spec/frontend/grafana_integration/components/__snapshots__/grafana_integration_spec.js.snap +++ b/spec/frontend/grafana_integration/components/__snapshots__/grafana_integration_spec.js.snap @@ -16,11 +16,11 @@ exports[`grafana integration component default state to match the default snapsh </h4> - <glbutton-stub + <gl-button-stub class="js-settings-toggle" > Expand - </glbutton-stub> + </gl-button-stub> <p class="js-section-sub-header" @@ -35,32 +35,32 @@ exports[`grafana integration component default state to match the default snapsh class="settings-content" > <form> - <glformcheckbox-stub + <gl-form-checkbox-stub class="mb-4" id="grafana-integration-enabled" > Active - </glformcheckbox-stub> + </gl-form-checkbox-stub> - <glformgroup-stub + <gl-form-group-stub description="Enter the base URL of the Grafana instance." label="Grafana URL" label-for="grafana-url" > - <glforminput-stub + <gl-form-input-stub id="grafana-url" placeholder="https://my-url.grafana.net/" value="http://test.host" /> - </glformgroup-stub> + </gl-form-group-stub> - <glformgroup-stub + <gl-form-group-stub label="API Token" label-for="grafana-token" > - <glforminput-stub + <gl-form-input-stub id="grafana-token" value="someToken" /> @@ -86,15 +86,15 @@ exports[`grafana integration component default state to match the default snapsh /> </a> </p> - </glformgroup-stub> + </gl-form-group-stub> - <glbutton-stub + <gl-button-stub variant="success" > Save Changes - </glbutton-stub> + </gl-button-stub> </form> </div> </section> diff --git a/spec/javascripts/header_spec.js b/spec/frontend/header_spec.js similarity index 97% rename from spec/javascripts/header_spec.js rename to spec/frontend/header_spec.js index c36d3be1b2291e4e40b0554e0cbfa984533c3a07..00b5b306d660cb331546e4de7783a59272c1e60a 100644 --- a/spec/javascripts/header_spec.js +++ b/spec/frontend/header_spec.js @@ -1,7 +1,7 @@ import $ from 'jquery'; import initTodoToggle from '~/header'; -describe('Header', function() { +describe('Header', () => { const todosPendingCount = '.todos-count'; const fixtureTemplate = 'issues/open-issue.html'; diff --git a/spec/javascripts/helpers/class_spec_helper_spec.js b/spec/frontend/helpers/class_spec_helper_spec.js similarity index 71% rename from spec/javascripts/helpers/class_spec_helper_spec.js rename to spec/frontend/helpers/class_spec_helper_spec.js index f6268b0fb6d1bbfaf426303cf8a838b0edb80c65..533d5687bdef1ca8246a11cf82f925cd1697c8ee 100644 --- a/spec/javascripts/helpers/class_spec_helper_spec.js +++ b/spec/frontend/helpers/class_spec_helper_spec.js @@ -2,7 +2,13 @@ import './class_spec_helper'; -describe('ClassSpecHelper', function() { +describe('ClassSpecHelper', () => { + let testContext; + + beforeEach(() => { + testContext = {}; + }); + describe('itShouldBeAStaticMethod', () => { beforeEach(() => { class TestClass { @@ -12,7 +18,7 @@ describe('ClassSpecHelper', function() { static staticMethod() {} } - this.TestClass = TestClass; + testContext.TestClass = TestClass; }); ClassSpecHelper.itShouldBeAStaticMethod(ClassSpecHelper, 'itShouldBeAStaticMethod'); diff --git a/spec/frontend/helpers/diffs_helper_spec.js b/spec/frontend/helpers/diffs_helper_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..b223d48bf5ccfc8926b30096e542f24f2366d334 --- /dev/null +++ b/spec/frontend/helpers/diffs_helper_spec.js @@ -0,0 +1,113 @@ +import * as diffsHelper from '~/helpers/diffs_helper'; + +describe('diffs helper', () => { + function getDiffFile(withOverrides = {}) { + return { + parallel_diff_lines: ['line'], + highlighted_diff_lines: ['line'], + blob: { + readable_text: 'text', + }, + ...withOverrides, + }; + } + + describe('hasInlineLines', () => { + it('is false when the file does not exist', () => { + expect(diffsHelper.hasInlineLines()).toBeFalsy(); + }); + + it('is false when the file does not have the highlighted_diff_lines property', () => { + const missingInline = getDiffFile({ highlighted_diff_lines: undefined }); + + expect(diffsHelper.hasInlineLines(missingInline)).toBeFalsy(); + }); + + it('is false when the file has zero highlighted_diff_lines', () => { + const emptyInline = getDiffFile({ highlighted_diff_lines: [] }); + + expect(diffsHelper.hasInlineLines(emptyInline)).toBeFalsy(); + }); + + it('is true when the file has at least 1 highlighted_diff_lines', () => { + expect(diffsHelper.hasInlineLines(getDiffFile())).toBeTruthy(); + }); + }); + + describe('hasParallelLines', () => { + it('is false when the file does not exist', () => { + expect(diffsHelper.hasParallelLines()).toBeFalsy(); + }); + + it('is false when the file does not have the parallel_diff_lines property', () => { + const missingInline = getDiffFile({ parallel_diff_lines: undefined }); + + expect(diffsHelper.hasParallelLines(missingInline)).toBeFalsy(); + }); + + it('is false when the file has zero parallel_diff_lines', () => { + const emptyInline = getDiffFile({ parallel_diff_lines: [] }); + + expect(diffsHelper.hasParallelLines(emptyInline)).toBeFalsy(); + }); + + it('is true when the file has at least 1 parallel_diff_lines', () => { + expect(diffsHelper.hasParallelLines(getDiffFile())).toBeTruthy(); + }); + }); + + describe('isSingleViewStyle', () => { + it('is true when the file has at least 1 inline line but no parallel lines for any reason', () => { + const noParallelLines = getDiffFile({ parallel_diff_lines: undefined }); + const emptyParallelLines = getDiffFile({ parallel_diff_lines: [] }); + + expect(diffsHelper.isSingleViewStyle(noParallelLines)).toBeTruthy(); + expect(diffsHelper.isSingleViewStyle(emptyParallelLines)).toBeTruthy(); + }); + + it('is true when the file has at least 1 parallel line but no inline lines for any reason', () => { + const noInlineLines = getDiffFile({ highlighted_diff_lines: undefined }); + const emptyInlineLines = getDiffFile({ highlighted_diff_lines: [] }); + + expect(diffsHelper.isSingleViewStyle(noInlineLines)).toBeTruthy(); + expect(diffsHelper.isSingleViewStyle(emptyInlineLines)).toBeTruthy(); + }); + + it('is true when the file does not have any inline lines or parallel lines for any reason', () => { + const noLines = getDiffFile({ + highlighted_diff_lines: undefined, + parallel_diff_lines: undefined, + }); + const emptyLines = getDiffFile({ + highlighted_diff_lines: [], + parallel_diff_lines: [], + }); + + expect(diffsHelper.isSingleViewStyle(noLines)).toBeTruthy(); + expect(diffsHelper.isSingleViewStyle(emptyLines)).toBeTruthy(); + expect(diffsHelper.isSingleViewStyle()).toBeTruthy(); + }); + + it('is false when the file has both inline and parallel lines', () => { + expect(diffsHelper.isSingleViewStyle(getDiffFile())).toBeFalsy(); + }); + }); + + describe.each` + context | inline | parallel | blob | expected + ${'only has inline lines'} | ${['line']} | ${undefined} | ${undefined} | ${true} + ${'only has parallel lines'} | ${undefined} | ${['line']} | ${undefined} | ${true} + ${"doesn't have inline, parallel, or blob"} | ${undefined} | ${undefined} | ${undefined} | ${true} + ${'has blob readable text'} | ${undefined} | ${undefined} | ${{ readable_text: 'text' }} | ${false} + `('when hasDiff', ({ context, inline, parallel, blob, expected }) => { + it(`${context}`, () => { + const diffFile = getDiffFile({ + highlighted_diff_lines: inline, + parallel_diff_lines: parallel, + blob, + }); + + expect(diffsHelper.hasDiff(diffFile)).toEqual(expected); + }); + }); +}); diff --git a/spec/frontend/helpers/stub_children.js b/spec/frontend/helpers/stub_children.js new file mode 100644 index 0000000000000000000000000000000000000000..91171eb3d8c63817cc3490d8b6adbb53413151be --- /dev/null +++ b/spec/frontend/helpers/stub_children.js @@ -0,0 +1,3 @@ +export default function stubChildren(Component) { + return Object.fromEntries(Object.keys(Component.components).map(c => [c, true])); +} diff --git a/spec/frontend/ide/components/branches/search_list_spec.js b/spec/frontend/ide/components/branches/search_list_spec.js index d26dfc48ff8141c70d0d50bcf044ec06d8fc5725..fe142d706984910fb57d1c3fceeb6e16ca5e491c 100644 --- a/spec/frontend/ide/components/branches/search_list_spec.js +++ b/spec/frontend/ide/components/branches/search_list_spec.js @@ -33,7 +33,6 @@ describe('IDE branches search list', () => { wrapper = shallowMount(List, { localVue, store: fakeStore, - sync: false, }); }; diff --git a/spec/frontend/ide/components/commit_sidebar/editor_header_spec.js b/spec/frontend/ide/components/commit_sidebar/editor_header_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..054e7492429f5150cdbde2ed5c480264448aaf3d --- /dev/null +++ b/spec/frontend/ide/components/commit_sidebar/editor_header_spec.js @@ -0,0 +1,82 @@ +import Vuex from 'vuex'; +import { mount, createLocalVue } from '@vue/test-utils'; +import { createStore } from '~/ide/stores'; +import EditorHeader from '~/ide/components/commit_sidebar/editor_header.vue'; +import { file } from '../../helpers'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('IDE commit editor header', () => { + let wrapper; + let f; + let store; + + const findDiscardModal = () => wrapper.find({ ref: 'discardModal' }); + const findDiscardButton = () => wrapper.find({ ref: 'discardButton' }); + const findActionButton = () => wrapper.find({ ref: 'actionButton' }); + + beforeEach(() => { + f = file('file'); + store = createStore(); + + wrapper = mount(EditorHeader, { + store, + localVue, + propsData: { + activeFile: f, + }, + }); + + jest.spyOn(wrapper.vm, 'stageChange').mockImplementation(); + jest.spyOn(wrapper.vm, 'unstageChange').mockImplementation(); + jest.spyOn(wrapper.vm, 'discardFileChanges').mockImplementation(); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('renders button to discard & stage', () => { + expect(wrapper.vm.$el.querySelectorAll('.btn').length).toBe(2); + }); + + describe('discard button', () => { + let modal; + + beforeEach(() => { + modal = findDiscardModal(); + + jest.spyOn(modal.vm, 'show'); + + findDiscardButton().trigger('click'); + }); + + it('opens a dialog confirming discard', () => { + expect(modal.vm.show).toHaveBeenCalled(); + }); + + it('calls discardFileChanges if dialog result is confirmed', () => { + modal.vm.$emit('ok'); + + expect(wrapper.vm.discardFileChanges).toHaveBeenCalledWith(f.path); + }); + }); + + describe('stage/unstage button', () => { + it('unstages the file if it was already staged', () => { + f.staged = true; + + findActionButton().trigger('click'); + + expect(wrapper.vm.unstageChange).toHaveBeenCalledWith(f.path); + }); + + it('stages the file if it was not staged', () => { + findActionButton().trigger('click'); + + expect(wrapper.vm.stageChange).toHaveBeenCalledWith(f.path); + }); + }); +}); diff --git a/spec/frontend/ide/components/error_message_spec.js b/spec/frontend/ide/components/error_message_spec.js index e995c64645e436640ed8d56e5833a9fb58a1e97c..1de496ba3f8afc382ad9a503690279a724b9f16d 100644 --- a/spec/frontend/ide/components/error_message_spec.js +++ b/spec/frontend/ide/components/error_message_spec.js @@ -26,7 +26,6 @@ describe('IDE error message component', () => { }, store: fakeStore, localVue, - sync: false, }); }; @@ -90,8 +89,13 @@ describe('IDE error message component', () => { it('does not dispatch action when already loading', () => { wrapper.find('button').trigger('click'); actionMock.mockReset(); - wrapper.find('button').trigger('click'); - expect(actionMock).not.toHaveBeenCalled(); + return wrapper.vm.$nextTick(() => { + wrapper.find('button').trigger('click'); + + return wrapper.vm.$nextTick().then(() => { + expect(actionMock).not.toHaveBeenCalled(); + }); + }); }); it('shows loading icon when loading', () => { diff --git a/spec/frontend/ide/components/file_templates/dropdown_spec.js b/spec/frontend/ide/components/file_templates/dropdown_spec.js index 83d797469ad7da3993b89b21dafe22bf651bcff7..3cffbc3362fc3add11e0fd02f7f25f8576ffd281 100644 --- a/spec/frontend/ide/components/file_templates/dropdown_spec.js +++ b/spec/frontend/ide/components/file_templates/dropdown_spec.js @@ -45,7 +45,6 @@ describe('IDE file templates dropdown component', () => { }, store: fakeStore, localVue, - sync: false, }); ({ element } = wrapper); @@ -62,7 +61,9 @@ describe('IDE file templates dropdown component', () => { const item = findItemButtons().at(0); item.trigger('click'); - expect(wrapper.emitted().click[0][0]).toBe(itemData); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.emitted().click[0][0]).toBe(itemData); + }); }); it('renders dropdown title', () => { diff --git a/spec/frontend/ide/components/ide_status_list_spec.js b/spec/frontend/ide/components/ide_status_list_spec.js index 4e0e8a9f0e3456e706a4cd5bc3d62c43525ee565..2762adfb57d38755a0f1868bf7a70d8ff523b32c 100644 --- a/spec/frontend/ide/components/ide_status_list_spec.js +++ b/spec/frontend/ide/components/ide_status_list_spec.js @@ -25,9 +25,8 @@ describe('ide/components/ide_status_list', () => { }, }); - wrapper = shallowMount(localVue.extend(IdeStatusList), { + wrapper = shallowMount(IdeStatusList, { localVue, - sync: false, store, ...options, }); diff --git a/spec/frontend/ide/components/jobs/__snapshots__/stage_spec.js.snap b/spec/frontend/ide/components/jobs/__snapshots__/stage_spec.js.snap index 5d6c31f01d9e5322e8d56bb6dbcd7b917ee00ff9..43e606eac6ebefc70e090abf1825805bf46fa19c 100644 --- a/spec/frontend/ide/components/jobs/__snapshots__/stage_spec.js.snap +++ b/spec/frontend/ide/components/jobs/__snapshots__/stage_spec.js.snap @@ -7,7 +7,7 @@ exports[`IDE pipeline stage renders stage details & icon 1`] = ` <div class="card-header" > - <ciicon-stub + <ci-icon-stub cssclasses="" size="24" status="[object Object]" diff --git a/spec/javascripts/ide/components/jobs/detail/scroll_button_spec.js b/spec/frontend/ide/components/jobs/detail/scroll_button_spec.js similarity index 95% rename from spec/javascripts/ide/components/jobs/detail/scroll_button_spec.js rename to spec/frontend/ide/components/jobs/detail/scroll_button_spec.js index fff382a107f7cfa81efb61e5c38836c32734d65b..096851a540196bc2c506e5a9563f958ec8ff0328 100644 --- a/spec/javascripts/ide/components/jobs/detail/scroll_button_spec.js +++ b/spec/frontend/ide/components/jobs/detail/scroll_button_spec.js @@ -40,7 +40,7 @@ describe('IDE job log scroll button', () => { }); it('emits click event on click', () => { - spyOn(vm, '$emit'); + jest.spyOn(vm, '$emit').mockImplementation(() => {}); vm.$el.querySelector('.btn-scroll').click(); diff --git a/spec/frontend/ide/components/jobs/list_spec.js b/spec/frontend/ide/components/jobs/list_spec.js index ec2e5b0504885dd1db8e1a69fbba1a7b0a0e180f..d8880fa7cb792d870ff12ecb23fef815dacdef04 100644 --- a/spec/frontend/ide/components/jobs/list_spec.js +++ b/spec/frontend/ide/components/jobs/list_spec.js @@ -44,7 +44,6 @@ describe('IDE stages list', () => { }, localVue, store, - sync: false, }); }; @@ -93,7 +92,6 @@ describe('IDE stages list', () => { wrapper = mount(StageList, { propsData: { ...defaultProps, stages }, store, - sync: false, localVue, }); }); diff --git a/spec/frontend/ide/components/jobs/stage_spec.js b/spec/frontend/ide/components/jobs/stage_spec.js index 2e42ab26d27630d249a796d381ba2565ef89c6e8..3a47571ee1387a0b00853c2f4a4fcf017a7b944a 100644 --- a/spec/frontend/ide/components/jobs/stage_spec.js +++ b/spec/frontend/ide/components/jobs/stage_spec.js @@ -26,7 +26,6 @@ describe('IDE pipeline stage', () => { ...defaultProps, ...props, }, - sync: false, }); }; @@ -52,7 +51,10 @@ describe('IDE pipeline stage', () => { const id = 5; createComponent({ stage: { ...defaultProps.stage, id } }); findHeader().trigger('click'); - expect(wrapper.emitted().toggleCollapsed[0][0]).toBe(id); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.emitted().toggleCollapsed[0][0]).toBe(id); + }); }); it('emits clickViewLog entity with job', () => { @@ -62,7 +64,9 @@ describe('IDE pipeline stage', () => { .findAll(Item) .at(0) .vm.$emit('clickViewLog', job); - expect(wrapper.emitted().clickViewLog[0][0]).toBe(job); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.emitted().clickViewLog[0][0]).toBe(job); + }); }); it('renders stage details & icon', () => { diff --git a/spec/frontend/ide/components/merge_requests/list_spec.js b/spec/frontend/ide/components/merge_requests/list_spec.js index 76806dcba69051b3da1e4d3a5583c4b429264f40..ae94ee4efa7095bc02e1fcceae70a2b6c5aaf437 100644 --- a/spec/frontend/ide/components/merge_requests/list_spec.js +++ b/spec/frontend/ide/components/merge_requests/list_spec.js @@ -42,7 +42,6 @@ describe('IDE merge requests list', () => { wrapper = shallowMount(List, { store: fakeStore, localVue, - sync: false, }); }; diff --git a/spec/frontend/ide/components/panes/collapsible_sidebar_spec.js b/spec/frontend/ide/components/panes/collapsible_sidebar_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..3bc899969787e41928c3efa5976477682fb7cb87 --- /dev/null +++ b/spec/frontend/ide/components/panes/collapsible_sidebar_spec.js @@ -0,0 +1,167 @@ +import { createLocalVue, shallowMount } from '@vue/test-utils'; +import { createStore } from '~/ide/stores'; +import paneModule from '~/ide/stores/modules/pane'; +import CollapsibleSidebar from '~/ide/components/panes/collapsible_sidebar.vue'; +import Vuex from 'vuex'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('ide/components/panes/collapsible_sidebar.vue', () => { + let wrapper; + let store; + + const width = 350; + const fakeComponentName = 'fake-component'; + + const createComponent = props => { + wrapper = shallowMount(CollapsibleSidebar, { + localVue, + store, + propsData: { + extensionTabs: [], + side: 'right', + width, + ...props, + }, + slots: { + 'header-icon': '<div class=".header-icon-slot">SLOT ICON</div>', + header: '<div class=".header-slot"/>', + footer: '<div class=".footer-slot"/>', + }, + }); + }; + + const findTabButton = () => wrapper.find(`[data-qa-selector="${fakeComponentName}_tab_button"]`); + + beforeEach(() => { + store = createStore(); + store.registerModule('leftPane', paneModule()); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('with a tab', () => { + let fakeView; + let extensionTabs; + + beforeEach(() => { + const FakeComponent = localVue.component(fakeComponentName, { + render: () => {}, + }); + + fakeView = { + name: fakeComponentName, + keepAlive: true, + component: FakeComponent, + }; + + extensionTabs = [ + { + show: true, + title: fakeComponentName, + views: [fakeView], + icon: 'text-description', + buttonClasses: ['button-class-1', 'button-class-2'], + }, + ]; + }); + + describe.each` + side + ${'left'} + ${'right'} + `('when side=$side', ({ side }) => { + it('correctly renders side specific attributes', () => { + createComponent({ extensionTabs, side }); + const button = findTabButton(); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.classes()).toContain('multi-file-commit-panel'); + expect(wrapper.classes()).toContain(`ide-${side}-sidebar`); + expect(wrapper.find('.multi-file-commit-panel-inner')).not.toBe(null); + expect(wrapper.find(`.ide-${side}-sidebar-${fakeComponentName}`)).not.toBe(null); + expect(button.attributes('data-placement')).toEqual(side === 'left' ? 'right' : 'left'); + if (side === 'right') { + // this class is only needed on the right side; there is no 'is-left' + expect(button.classes()).toContain('is-right'); + } else { + expect(button.classes()).not.toContain('is-right'); + } + }); + }); + }); + + describe('when default side', () => { + let button; + + beforeEach(() => { + createComponent({ extensionTabs }); + + button = findTabButton(); + }); + + it('correctly renders tab-specific classes', () => { + store.state.rightPane.currentView = fakeComponentName; + + return wrapper.vm.$nextTick().then(() => { + expect(button.classes()).toContain('button-class-1'); + expect(button.classes()).toContain('button-class-2'); + }); + }); + + it('can show an open pane tab with an active view', () => { + store.state.rightPane.isOpen = true; + store.state.rightPane.currentView = fakeComponentName; + + return wrapper.vm.$nextTick().then(() => { + expect(button.classes()).toEqual(expect.arrayContaining(['ide-sidebar-link', 'active'])); + expect(button.attributes('data-original-title')).toEqual(fakeComponentName); + expect(wrapper.find('.js-tab-view').exists()).toBe(true); + }); + }); + + it('does not show a pane which is not open', () => { + store.state.rightPane.isOpen = false; + store.state.rightPane.currentView = fakeComponentName; + + return wrapper.vm.$nextTick().then(() => { + expect(button.classes()).not.toEqual( + expect.arrayContaining(['ide-sidebar-link', 'active']), + ); + expect(wrapper.find('.js-tab-view').exists()).toBe(false); + }); + }); + + describe('when button is clicked', () => { + it('opens view', () => { + button.trigger('click'); + expect(store.state.rightPane.isOpen).toBeTruthy(); + }); + + it('toggles open view if tab is currently active', () => { + button.trigger('click'); + expect(store.state.rightPane.isOpen).toBeTruthy(); + + button.trigger('click'); + expect(store.state.rightPane.isOpen).toBeFalsy(); + }); + }); + + it('shows header-icon', () => { + expect(wrapper.find('.header-icon-slot')).not.toBeNull(); + }); + + it('shows header', () => { + expect(wrapper.find('.header-slot')).not.toBeNull(); + }); + + it('shows footer', () => { + expect(wrapper.find('.footer-slot')).not.toBeNull(); + }); + }); + }); +}); diff --git a/spec/frontend/ide/components/panes/right_spec.js b/spec/frontend/ide/components/panes/right_spec.js index 6908790aaa8dbf9b26f54783952c079ca4c58b4b..7e408be96fcff4545d7d33c5c622f69624c5c94b 100644 --- a/spec/frontend/ide/components/panes/right_spec.js +++ b/spec/frontend/ide/components/panes/right_spec.js @@ -1,89 +1,124 @@ import Vue from 'vue'; -import '~/behaviors/markdown/render_gfm'; -import { createComponentWithStore } from 'helpers/vue_mount_component_helper'; +import Vuex from 'vuex'; +import { createLocalVue, shallowMount } from '@vue/test-utils'; import { createStore } from '~/ide/stores'; import RightPane from '~/ide/components/panes/right.vue'; +import CollapsibleSidebar from '~/ide/components/panes/collapsible_sidebar.vue'; import { rightSidebarViews } from '~/ide/constants'; -describe('IDE right pane', () => { - let Component; - let vm; +const localVue = createLocalVue(); +localVue.use(Vuex); - beforeAll(() => { - Component = Vue.extend(RightPane); - }); +describe('ide/components/panes/right.vue', () => { + let wrapper; + let store; - beforeEach(() => { - const store = createStore(); + const createComponent = props => { + wrapper = shallowMount(RightPane, { + localVue, + store, + propsData: { + ...props, + }, + }); + }; - vm = createComponentWithStore(Component, store).$mount(); + beforeEach(() => { + store = createStore(); }); afterEach(() => { - vm.$destroy(); + wrapper.destroy(); + wrapper = null; }); - describe('active', () => { - it('renders merge request button as active', done => { - vm.$store.state.rightPane.isOpen = true; - vm.$store.state.rightPane.currentView = rightSidebarViews.mergeRequestInfo.name; - vm.$store.state.currentMergeRequestId = '123'; - vm.$store.state.currentProjectId = 'gitlab-ce'; - vm.$store.state.currentMergeRequestId = 1; - vm.$store.state.projects['gitlab-ce'] = { - mergeRequests: { - 1: { - iid: 1, - title: 'Testing', - title_html: '<span class="title-html">Testing</span>', - description: 'Description', - description_html: '<p class="description-html">Description HTML</p>', - }, + it('allows tabs to be added via extensionTabs prop', () => { + createComponent({ + extensionTabs: [ + { + show: true, + title: 'FakeTab', }, - }; - - vm.$nextTick() - .then(() => { - expect(vm.$el.querySelector('.ide-sidebar-link.active')).not.toBe(null); - expect( - vm.$el.querySelector('.ide-sidebar-link.active').getAttribute('data-original-title'), - ).toBe('Merge Request'); - }) - .then(done) - .catch(done.fail); + ], }); + + expect(wrapper.find(CollapsibleSidebar).props('extensionTabs')).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + show: true, + title: 'FakeTab', + }), + ]), + ); }); - describe('click', () => { - beforeEach(() => { - jest.spyOn(vm, 'open').mockReturnValue(); - }); + describe('pipelines tab', () => { + it('is always shown', () => { + createComponent(); - it('sets view to merge request', done => { - vm.$store.state.currentMergeRequestId = '123'; + expect(wrapper.find(CollapsibleSidebar).props('extensionTabs')).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + show: true, + title: 'Pipelines', + views: expect.arrayContaining([ + expect.objectContaining({ + name: rightSidebarViews.pipelines.name, + }), + expect.objectContaining({ + name: rightSidebarViews.jobsDetail.name, + }), + ]), + }), + ]), + ); + }); + }); - vm.$nextTick(() => { - vm.$el.querySelector('.ide-sidebar-link').click(); + describe('merge request tab', () => { + it('is shown if there is a currentMergeRequestId', () => { + store.state.currentMergeRequestId = 1; - expect(vm.open).toHaveBeenCalledWith(rightSidebarViews.mergeRequestInfo); + createComponent(); - done(); - }); + expect(wrapper.find(CollapsibleSidebar).props('extensionTabs')).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + show: true, + title: 'Merge Request', + views: expect.arrayContaining([ + expect.objectContaining({ + name: rightSidebarViews.mergeRequestInfo.name, + }), + ]), + }), + ]), + ); }); }); - describe('live preview', () => { - it('renders live preview button', done => { - Vue.set(vm.$store.state.entries, 'package.json', { + describe('clientside live preview tab', () => { + it('is shown if there is a packageJson and clientsidePreviewEnabled', () => { + Vue.set(store.state.entries, 'package.json', { name: 'package.json', }); - vm.$store.state.clientsidePreviewEnabled = true; + store.state.clientsidePreviewEnabled = true; - vm.$nextTick(() => { - expect(vm.$el.querySelector('button[aria-label="Live preview"]')).not.toBeNull(); + createComponent(); - done(); - }); + expect(wrapper.find(CollapsibleSidebar).props('extensionTabs')).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + show: true, + title: 'Live preview', + views: expect.arrayContaining([ + expect.objectContaining({ + name: rightSidebarViews.clientSidePreview.name, + }), + ]), + }), + ]), + ); }); }); }); diff --git a/spec/frontend/ide/components/pipelines/__snapshots__/list_spec.js.snap b/spec/frontend/ide/components/pipelines/__snapshots__/list_spec.js.snap index 5fbe6af750decd2a96eab00dbf7d42595aaf3401..177cd4559ca4032b5703918485be21a7b38987c3 100644 --- a/spec/frontend/ide/components/pipelines/__snapshots__/list_spec.js.snap +++ b/spec/frontend/ide/components/pipelines/__snapshots__/list_spec.js.snap @@ -6,7 +6,7 @@ exports[`IDE pipelines list when loaded renders empty state when no latestPipeli > <!----> - <emptystate-stub + <empty-state-stub cansetci="true" emptystatesvgpath="http://test.host" helppagepath="http://test.host" diff --git a/spec/frontend/ide/components/pipelines/list_spec.js b/spec/frontend/ide/components/pipelines/list_spec.js index 91152dffafa527be8fa195926d5a8c04e9c63dec..11e672b6685b4f4642a458591ce5c324cee7ec19 100644 --- a/spec/frontend/ide/components/pipelines/list_spec.js +++ b/spec/frontend/ide/components/pipelines/list_spec.js @@ -63,7 +63,6 @@ describe('IDE pipelines list', () => { wrapper = shallowMount(List, { localVue, store: fakeStore, - sync: false, }); }; diff --git a/spec/frontend/ide/components/preview/clientside_spec.js b/spec/frontend/ide/components/preview/clientside_spec.js index 5cb9e598fc42b5574b1d6777eae8bcd504e82667..c7d5ea9c5131d9e66116ceebb718704499f7effe 100644 --- a/spec/frontend/ide/components/preview/clientside_spec.js +++ b/spec/frontend/ide/components/preview/clientside_spec.js @@ -54,7 +54,6 @@ describe('IDE clientside preview', () => { }); wrapper = shallowMount(Clientside, { - sync: false, store, localVue, }); diff --git a/spec/javascripts/ide/stores/actions/file_spec.js b/spec/frontend/ide/stores/actions/file_spec.js similarity index 68% rename from spec/javascripts/ide/stores/actions/file_spec.js rename to spec/frontend/ide/stores/actions/file_spec.js index 03d1125c23a699f42e3d2a5281c1d4c134cc7b4c..a8e48f0b85efd684038ab7693eb20976ffc23ee9 100644 --- a/spec/javascripts/ide/stores/actions/file_spec.js +++ b/spec/frontend/ide/stores/actions/file_spec.js @@ -1,20 +1,21 @@ import Vue from 'vue'; import MockAdapter from 'axios-mock-adapter'; import axios from '~/lib/utils/axios_utils'; -import store from '~/ide/stores'; +import { createStore } from '~/ide/stores'; import * as actions from '~/ide/stores/actions/file'; import * as types from '~/ide/stores/mutation_types'; import service from '~/ide/services'; import router from '~/ide/ide_router'; import eventHub from '~/ide/eventhub'; -import { file, resetStore } from '../../helpers'; -import testAction from '../../../helpers/vuex_action_helper'; +import { file } from '../../helpers'; +const ORIGINAL_CONTENT = 'original content'; const RELATIVE_URL_ROOT = '/gitlab'; describe('IDE store file actions', () => { let mock; let originalGon; + let store; beforeEach(() => { mock = new MockAdapter(axios); @@ -24,12 +25,15 @@ describe('IDE store file actions', () => { relative_url_root: RELATIVE_URL_ROOT, }; - spyOn(router, 'push'); + store = createStore(); + + jest.spyOn(store, 'commit'); + jest.spyOn(store, 'dispatch'); + jest.spyOn(router, 'push').mockImplementation(() => {}); }); afterEach(() => { mock.restore(); - resetStore(store); window.gon = originalGon; }); @@ -117,7 +121,7 @@ describe('IDE store file actions', () => { let oldScrollToTab; beforeEach(() => { - scrollToTabSpy = jasmine.createSpy('scrollToTab'); + scrollToTabSpy = jest.fn(); oldScrollToTab = store._actions.scrollToTab; // eslint-disable-line store._actions.scrollToTab = [scrollToTabSpy]; // eslint-disable-line @@ -131,7 +135,7 @@ describe('IDE store file actions', () => { }); it('calls scrollToTab', () => { - const dispatch = jasmine.createSpy(); + const dispatch = jest.fn(); actions.setFileActive( { commit() {}, state: store.state, getters: store.getters, dispatch }, @@ -142,7 +146,7 @@ describe('IDE store file actions', () => { }); it('commits SET_FILE_ACTIVE', () => { - const commit = jasmine.createSpy(); + const commit = jest.fn(); actions.setFileActive( { commit, state: store.state, getters: store.getters, dispatch() {} }, @@ -161,7 +165,7 @@ describe('IDE store file actions', () => { localFile.active = true; store.state.openFiles.push(localFile); - const commit = jasmine.createSpy(); + const commit = jest.fn(); actions.setFileActive( { commit, state: store.state, getters: store.getters, dispatch() {} }, @@ -179,7 +183,7 @@ describe('IDE store file actions', () => { let localFile; beforeEach(() => { - spyOn(service, 'getFileData').and.callThrough(); + jest.spyOn(service, 'getFileData'); localFile = file(`newCreate-${Math.random()}`); store.state.entries[localFile.path] = localFile; @@ -198,6 +202,53 @@ describe('IDE store file actions', () => { }; }); + describe('call to service', () => { + const callExpectation = serviceCalled => { + store.dispatch('getFileData', { path: localFile.path }); + + if (serviceCalled) { + expect(service.getFileData).toHaveBeenCalled(); + } else { + expect(service.getFileData).not.toHaveBeenCalled(); + } + }; + + beforeEach(() => { + service.getFileData.mockImplementation(() => new Promise(() => {})); + }); + + it("isn't called if file.raw exists", () => { + localFile.raw = 'raw data'; + + callExpectation(false); + }); + + it("isn't called if file is a tempFile", () => { + localFile.raw = ''; + localFile.tempFile = true; + + callExpectation(false); + }); + + it('is called if file is a tempFile but also renamed', () => { + localFile.raw = ''; + localFile.tempFile = true; + localFile.prevPath = 'old_path'; + + callExpectation(true); + }); + + it('is called if tempFile but file was deleted and readded', () => { + localFile.raw = ''; + localFile.tempFile = true; + localFile.prevPath = 'old_path'; + + store.state.stagedFiles = [{ ...localFile, deleted: true }]; + + callExpectation(true); + }); + }); + describe('success', () => { beforeEach(() => { mock.onGet(`${RELATIVE_URL_ROOT}/test/test/7297abc/${localFile.path}`).replyOnce( @@ -328,10 +379,10 @@ describe('IDE store file actions', () => { mock.onGet(`${RELATIVE_URL_ROOT}/test/test/7297abc/${localFile.path}`).networkError(); }); - it('dispatches error action', done => { - const dispatch = jasmine.createSpy('dispatch'); + it('dispatches error action', () => { + const dispatch = jest.fn(); - actions + return actions .getFileData( { state: store.state, commit() {}, dispatch, getters: store.getters }, { path: localFile.path }, @@ -339,17 +390,14 @@ describe('IDE store file actions', () => { .then(() => { expect(dispatch).toHaveBeenCalledWith('setErrorMessage', { text: 'An error occurred whilst loading the file.', - action: jasmine.any(Function), + action: expect.any(Function), actionText: 'Please try again', actionPayload: { path: localFile.path, makeFileActive: true, }, }); - - done(); - }) - .catch(done.fail); + }); }); }); }); @@ -358,7 +406,7 @@ describe('IDE store file actions', () => { let tmpFile; beforeEach(() => { - spyOn(service, 'getRawFileData').and.callThrough(); + jest.spyOn(service, 'getRawFileData'); tmpFile = file('tmpFile'); store.state.entries[tmpFile.path] = tmpFile; @@ -392,7 +440,7 @@ describe('IDE store file actions', () => { }); it('calls also getBaseRawFileData service method', done => { - spyOn(service, 'getBaseRawFileData').and.returnValue(Promise.resolve('baseraw')); + jest.spyOn(service, 'getBaseRawFileData').mockReturnValue(Promise.resolve('baseraw')); store.state.currentProjectId = 'gitlab-org/gitlab-ce'; store.state.currentMergeRequestId = '1'; @@ -442,23 +490,23 @@ describe('IDE store file actions', () => { mock.onGet(/(.*)/).networkError(); }); - it('dispatches error action', done => { - const dispatch = jasmine.createSpy('dispatch'); + it('dispatches error action', () => { + const dispatch = jest.fn(); - actions - .getRawFileData({ state: store.state, commit() {}, dispatch }, { path: tmpFile.path }) - .then(done.fail) + return actions + .getRawFileData( + { state: store.state, commit() {}, dispatch, getters: store.getters }, + { path: tmpFile.path }, + ) .catch(() => { expect(dispatch).toHaveBeenCalledWith('setErrorMessage', { text: 'An error occurred whilst loading the file content.', - action: jasmine.any(Function), + action: expect.any(Function), actionText: 'Please try again', actionPayload: { path: tmpFile.path, }, }); - - done(); }); }); }); @@ -466,6 +514,8 @@ describe('IDE store file actions', () => { describe('changeFileContent', () => { let tmpFile; + const callAction = (content = 'content\n') => + store.dispatch('changeFileContent', { path: tmpFile.path, content }); beforeEach(() => { tmpFile = file('tmpFile'); @@ -475,11 +525,7 @@ describe('IDE store file actions', () => { }); it('updates file content', done => { - store - .dispatch('changeFileContent', { - path: tmpFile.path, - content: 'content\n', - }) + callAction() .then(() => { expect(tmpFile.content).toBe('content\n'); @@ -489,11 +535,7 @@ describe('IDE store file actions', () => { }); it('adds a newline to the end of the file if it doesnt already exist', done => { - store - .dispatch('changeFileContent', { - path: tmpFile.path, - content: 'content', - }) + callAction('content') .then(() => { expect(tmpFile.content).toBe('content\n'); @@ -503,11 +545,7 @@ describe('IDE store file actions', () => { }); it('adds file into changedFiles array', done => { - store - .dispatch('changeFileContent', { - path: tmpFile.path, - content: 'content', - }) + callAction() .then(() => { expect(store.state.changedFiles.length).toBe(1); @@ -516,7 +554,7 @@ describe('IDE store file actions', () => { .catch(done.fail); }); - it('adds file once into changedFiles array', done => { + it('adds file not more than once into changedFiles array', done => { store .dispatch('changeFileContent', { path: tmpFile.path, @@ -556,6 +594,52 @@ describe('IDE store file actions', () => { .catch(done.fail); }); + describe('when `gon.feature.stageAllByDefault` is true', () => { + const originalGonFeatures = Object.assign({}, gon.features); + + beforeAll(() => { + gon.features = { stageAllByDefault: true }; + }); + + afterAll(() => { + gon.features = originalGonFeatures; + }); + + it('adds file into stagedFiles array', done => { + store + .dispatch('changeFileContent', { + path: tmpFile.path, + content: 'content', + }) + .then(() => { + expect(store.state.stagedFiles.length).toBe(1); + + done(); + }) + .catch(done.fail); + }); + + it('adds file not more than once into stagedFiles array', done => { + store + .dispatch('changeFileContent', { + path: tmpFile.path, + content: 'content', + }) + .then(() => + store.dispatch('changeFileContent', { + path: tmpFile.path, + content: 'content 123', + }), + ) + .then(() => { + expect(store.state.stagedFiles.length).toBe(1); + + done(); + }) + .catch(done.fail); + }); + }); + it('bursts unused seal', done => { store .dispatch('changeFileContent', { @@ -571,122 +655,144 @@ describe('IDE store file actions', () => { }); }); - describe('discardFileChanges', () => { + describe('with changed file', () => { let tmpFile; beforeEach(() => { - spyOn(eventHub, '$on'); - spyOn(eventHub, '$emit'); - - tmpFile = file(); + tmpFile = file('tempFile'); tmpFile.content = 'testing'; + tmpFile.raw = ORIGINAL_CONTENT; store.state.changedFiles.push(tmpFile); store.state.entries[tmpFile.path] = tmpFile; }); - it('resets file content', done => { - store - .dispatch('discardFileChanges', tmpFile.path) - .then(() => { - expect(tmpFile.content).not.toBe('testing'); + describe('restoreOriginalFile', () => { + it('resets file content', () => + store.dispatch('restoreOriginalFile', tmpFile.path).then(() => { + expect(tmpFile.content).toBe(ORIGINAL_CONTENT); + })); - done(); - }) - .catch(done.fail); - }); + it('closes temp file and deletes it', () => { + tmpFile.tempFile = true; + tmpFile.opened = true; + tmpFile.parentPath = 'parentFile'; + store.state.entries.parentFile = file('parentFile'); - it('removes file from changedFiles array', done => { - store - .dispatch('discardFileChanges', tmpFile.path) - .then(() => { - expect(store.state.changedFiles.length).toBe(0); + actions.restoreOriginalFile(store, tmpFile.path); - done(); - }) - .catch(done.fail); - }); + expect(store.dispatch).toHaveBeenCalledWith('closeFile', tmpFile); + expect(store.dispatch).toHaveBeenCalledWith('deleteEntry', tmpFile.path); + }); - it('closes temp file', done => { - tmpFile.tempFile = true; - tmpFile.opened = true; + describe('with renamed file', () => { + beforeEach(() => { + Object.assign(tmpFile, { + prevPath: 'parentPath/old_name', + prevName: 'old_name', + prevParentPath: 'parentPath', + }); - store - .dispatch('discardFileChanges', tmpFile.path) - .then(() => { - expect(tmpFile.opened).toBeFalsy(); + store.state.entries.parentPath = file('parentPath'); - done(); - }) - .catch(done.fail); - }); + actions.restoreOriginalFile(store, tmpFile.path); + }); - it('does not re-open a closed temp file', done => { - tmpFile.tempFile = true; + it('renames the file to its original name and closes it if it was open', () => { + expect(store.dispatch).toHaveBeenCalledWith('closeFile', tmpFile); + expect(store.dispatch).toHaveBeenCalledWith('renameEntry', { + path: 'tempFile', + name: 'old_name', + parentPath: 'parentPath', + }); + }); - expect(tmpFile.opened).toBeFalsy(); + it('resets file content', () => { + expect(tmpFile.content).toBe(ORIGINAL_CONTENT); + }); + }); + }); - store - .dispatch('discardFileChanges', tmpFile.path) - .then(() => { - expect(tmpFile.opened).toBeFalsy(); + describe('discardFileChanges', () => { + beforeEach(() => { + jest.spyOn(eventHub, '$on').mockImplementation(() => {}); + jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); + }); - done(); - }) - .catch(done.fail); - }); + describe('with regular file', () => { + beforeEach(() => { + actions.discardFileChanges(store, tmpFile.path); + }); - it('pushes route for active file', done => { - tmpFile.active = true; - store.state.openFiles.push(tmpFile); + it('restores original file', () => { + expect(store.dispatch).toHaveBeenCalledWith('restoreOriginalFile', tmpFile.path); + }); - store - .dispatch('discardFileChanges', tmpFile.path) - .then(() => { - expect(router.push).toHaveBeenCalledWith(`/project${tmpFile.url}`); + it('removes file from changedFiles array', () => { + expect(store.state.changedFiles.length).toBe(0); + }); + + it('does not push a new route', () => { + expect(router.push).not.toHaveBeenCalled(); + }); + + it('emits eventHub event to dispose cached model', () => { + actions.discardFileChanges(store, tmpFile.path); + + expect(eventHub.$emit).toHaveBeenCalledWith( + `editor.update.model.new.content.${tmpFile.key}`, + ORIGINAL_CONTENT, + ); + expect(eventHub.$emit).toHaveBeenCalledWith( + `editor.update.model.dispose.unstaged-${tmpFile.key}`, + ORIGINAL_CONTENT, + ); + }); + }); - done(); - }) - .catch(done.fail); - }); + describe('with active file', () => { + beforeEach(() => { + tmpFile.active = true; + store.state.openFiles.push(tmpFile); - it('emits eventHub event to dispose cached model', done => { - store - .dispatch('discardFileChanges', tmpFile.path) - .then(() => { - expect(eventHub.$emit).toHaveBeenCalled(); + actions.discardFileChanges(store, tmpFile.path); + }); - done(); - }) - .catch(done.fail); + it('pushes route for active file', () => { + expect(router.push).toHaveBeenCalledWith(`/project${tmpFile.url}`); + }); + }); }); }); describe('stageChange', () => { - it('calls STAGE_CHANGE with file path', done => { - testAction( - actions.stageChange, - 'path', - store.state, - [ - { type: types.STAGE_CHANGE, payload: 'path' }, - { type: types.SET_LAST_COMMIT_MSG, payload: '' }, - ], - [], - done, + it('calls STAGE_CHANGE with file path', () => { + const f = { ...file('path'), content: 'old' }; + + store.state.entries[f.path] = f; + + actions.stageChange(store, 'path'); + + expect(store.commit).toHaveBeenCalledWith( + types.STAGE_CHANGE, + expect.objectContaining({ path: 'path' }), ); + expect(store.commit).toHaveBeenCalledWith(types.SET_LAST_COMMIT_MSG, ''); }); }); describe('unstageChange', () => { - it('calls UNSTAGE_CHANGE with file path', done => { - testAction( - actions.unstageChange, - 'path', - store.state, - [{ type: types.UNSTAGE_CHANGE, payload: 'path' }], - [], - done, + it('calls UNSTAGE_CHANGE with file path', () => { + const f = { ...file('path'), content: 'old' }; + + store.state.entries[f.path] = f; + store.state.stagedFiles.push({ f, content: 'new' }); + + actions.unstageChange(store, 'path'); + + expect(store.commit).toHaveBeenCalledWith( + types.UNSTAGE_CHANGE, + expect.objectContaining({ path: 'path' }), ); }); }); @@ -756,7 +862,7 @@ describe('IDE store file actions', () => { let f; beforeEach(() => { - spyOn(eventHub, '$emit'); + jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); f = { ...file('pendingFile'), @@ -789,7 +895,7 @@ describe('IDE store file actions', () => { describe('triggerFilesChange', () => { beforeEach(() => { - spyOn(eventHub, '$emit'); + jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); }); it('emits event that files have changed', done => { diff --git a/spec/frontend/ide/stores/modules/pane/actions_spec.js b/spec/frontend/ide/stores/modules/pane/actions_spec.js index 8c0aeaff5b3b18437813f1d65a4f99b85b886a6e..8c56714e0edd7e719c0d307a8a76a1e7ba80607f 100644 --- a/spec/frontend/ide/stores/modules/pane/actions_spec.js +++ b/spec/frontend/ide/stores/modules/pane/actions_spec.js @@ -8,14 +8,7 @@ describe('IDE pane module actions', () => { describe('toggleOpen', () => { it('dispatches open if closed', done => { - testAction( - actions.toggleOpen, - TEST_VIEW, - { isOpen: false }, - [], - [{ type: 'open', payload: TEST_VIEW }], - done, - ); + testAction(actions.toggleOpen, TEST_VIEW, { isOpen: false }, [], [{ type: 'open' }], done); }); it('dispatches close if opened', done => { @@ -24,37 +17,48 @@ describe('IDE pane module actions', () => { }); describe('open', () => { - it('commits SET_OPEN', done => { - testAction(actions.open, null, {}, [{ type: types.SET_OPEN, payload: true }], [], done); - }); + describe('with a view specified', () => { + it('commits SET_OPEN and SET_CURRENT_VIEW', done => { + testAction( + actions.open, + TEST_VIEW, + {}, + [ + { type: types.SET_OPEN, payload: true }, + { type: types.SET_CURRENT_VIEW, payload: TEST_VIEW.name }, + ], + [], + done, + ); + }); - it('commits SET_CURRENT_VIEW if view is given', done => { - testAction( - actions.open, - TEST_VIEW, - {}, - [ - { type: types.SET_OPEN, payload: true }, - { type: types.SET_CURRENT_VIEW, payload: TEST_VIEW.name }, - ], - [], - done, - ); + it('commits KEEP_ALIVE_VIEW if keepAlive is true', done => { + testAction( + actions.open, + TEST_VIEW_KEEP_ALIVE, + {}, + [ + { type: types.SET_OPEN, payload: true }, + { type: types.SET_CURRENT_VIEW, payload: TEST_VIEW_KEEP_ALIVE.name }, + { type: types.KEEP_ALIVE_VIEW, payload: TEST_VIEW_KEEP_ALIVE.name }, + ], + [], + done, + ); + }); }); - it('commits KEEP_ALIVE_VIEW if keepAlive is true', done => { - testAction( - actions.open, - TEST_VIEW_KEEP_ALIVE, - {}, - [ - { type: types.SET_OPEN, payload: true }, - { type: types.SET_CURRENT_VIEW, payload: TEST_VIEW_KEEP_ALIVE.name }, - { type: types.KEEP_ALIVE_VIEW, payload: TEST_VIEW_KEEP_ALIVE.name }, - ], - [], - done, - ); + describe('without a view specified', () => { + it('commits SET_OPEN', done => { + testAction( + actions.open, + undefined, + {}, + [{ type: types.SET_OPEN, payload: true }], + [], + done, + ); + }); }); }); diff --git a/spec/frontend/ide/stores/mutations/file_spec.js b/spec/frontend/ide/stores/mutations/file_spec.js index 91506c1b46c558c4e7ddbaaf9a3434b212b3a785..cd308ee9991f77759d9a2167780fa7f19f654c5f 100644 --- a/spec/frontend/ide/stores/mutations/file_spec.js +++ b/spec/frontend/ide/stores/mutations/file_spec.js @@ -1,15 +1,17 @@ import mutations from '~/ide/stores/mutations/file'; -import state from '~/ide/stores/state'; +import { createStore } from '~/ide/stores'; import { FILE_VIEW_MODE_PREVIEW } from '~/ide/constants'; import { file } from '../../helpers'; describe('IDE store file mutations', () => { let localState; + let localStore; let localFile; beforeEach(() => { - localState = state(); - localFile = { ...file(), type: 'blob' }; + localStore = createStore(); + localState = localStore.state; + localFile = { ...file('file'), type: 'blob', content: 'original' }; localState.entries[localFile.path] = localFile; }); @@ -137,35 +139,68 @@ describe('IDE store file mutations', () => { }); describe('SET_FILE_RAW_DATA', () => { - it('sets raw data', () => { + const callMutationForFile = f => { mutations.SET_FILE_RAW_DATA(localState, { - file: localFile, + file: f, raw: 'testing', + fileDeletedAndReadded: localStore.getters.isFileDeletedAndReadded(localFile.path), }); + }; + + it('sets raw data', () => { + callMutationForFile(localFile); expect(localFile.raw).toBe('testing'); }); + it('sets raw data to stagedFile if file was deleted and readded', () => { + localState.stagedFiles = [{ ...localFile, deleted: true }]; + localFile.tempFile = true; + + callMutationForFile(localFile); + + expect(localFile.raw).toBeFalsy(); + expect(localState.stagedFiles[0].raw).toBe('testing'); + }); + + it("sets raw data to a file's content if tempFile is empty", () => { + localFile.tempFile = true; + localFile.content = ''; + + callMutationForFile(localFile); + + expect(localFile.raw).toBeFalsy(); + expect(localFile.content).toBe('testing'); + }); + it('adds raw data to open pending file', () => { localState.openFiles.push({ ...localFile, pending: true }); - mutations.SET_FILE_RAW_DATA(localState, { - file: localFile, - raw: 'testing', - }); + callMutationForFile(localFile); expect(localState.openFiles[0].raw).toBe('testing'); }); - it('does not add raw data to open pending tempFile file', () => { - localState.openFiles.push({ ...localFile, pending: true, tempFile: true }); + it('sets raw to content of a renamed tempFile', () => { + localFile.tempFile = true; + localFile.prevPath = 'old_path'; + localState.openFiles.push({ ...localFile, pending: true }); - mutations.SET_FILE_RAW_DATA(localState, { - file: localFile, - raw: 'testing', - }); + callMutationForFile(localFile); expect(localState.openFiles[0].raw).not.toBe('testing'); + expect(localState.openFiles[0].content).toBe('testing'); + }); + + it('adds raw data to a staged deleted file if unstaged change has a tempFile of the same name', () => { + localFile.tempFile = true; + localState.openFiles.push({ ...localFile, pending: true }); + localState.stagedFiles = [{ ...localFile, deleted: true }]; + + callMutationForFile(localFile); + + expect(localFile.raw).toBeFalsy(); + expect(localState.stagedFiles[0].raw).toBe('testing'); }); }); @@ -333,44 +368,154 @@ describe('IDE store file mutations', () => { }); }); - describe('STAGE_CHANGE', () => { - beforeEach(() => { - mutations.STAGE_CHANGE(localState, localFile.path); - }); + describe.each` + mutationName | mutation | addedTo | removedFrom | staged | changedFilesCount | stagedFilesCount + ${'STAGE_CHANGE'} | ${mutations.STAGE_CHANGE} | ${'stagedFiles'} | ${'changedFiles'} | ${true} | ${0} | ${1} + ${'UNSTAGE_CHANGE'} | ${mutations.UNSTAGE_CHANGE} | ${'changedFiles'} | ${'stagedFiles'} | ${false} | ${1} | ${0} + `( + '$mutationName', + ({ mutation, changedFilesCount, removedFrom, addedTo, staged, stagedFilesCount }) => { + let unstagedFile; + let stagedFile; + + beforeEach(() => { + unstagedFile = { + ...file('file'), + type: 'blob', + raw: 'original content', + content: 'changed content', + }; + + stagedFile = { + ...unstagedFile, + content: 'staged content', + staged: true, + }; + + localState.changedFiles.push(unstagedFile); + localState.stagedFiles.push(stagedFile); + localState.entries[unstagedFile.path] = unstagedFile; + }); - it('adds file into stagedFiles array', () => { - expect(localState.stagedFiles.length).toBe(1); - expect(localState.stagedFiles[0]).toEqual(localFile); - }); + it('removes all changes of a file if staged and unstaged change contents are equal', () => { + unstagedFile.content = 'original content'; + + mutation(localState, { + path: unstagedFile.path, + diffInfo: localStore.getters.getDiffInfo(unstagedFile.path), + }); + + expect(localState.entries.file).toEqual( + expect.objectContaining({ + content: 'original content', + staged: false, + changed: false, + }), + ); - it('updates stagedFile if it is already staged', () => { - localFile.raw = 'testing 123'; + expect(localState.stagedFiles.length).toBe(0); + expect(localState.changedFiles.length).toBe(0); + }); - mutations.STAGE_CHANGE(localState, localFile.path); + it('removes all changes of a file if a file is deleted and a new file with same content is added', () => { + stagedFile.deleted = true; + unstagedFile.tempFile = true; + unstagedFile.content = 'original content'; - expect(localState.stagedFiles.length).toBe(1); - expect(localState.stagedFiles[0].raw).toEqual('testing 123'); - }); - }); + mutation(localState, { + path: unstagedFile.path, + diffInfo: localStore.getters.getDiffInfo(unstagedFile.path), + }); - describe('UNSTAGE_CHANGE', () => { - let f; + expect(localState.stagedFiles.length).toBe(0); + expect(localState.changedFiles.length).toBe(0); - beforeEach(() => { - f = { ...file(), type: 'blob', staged: true }; + expect(localState.entries.file).toEqual( + expect.objectContaining({ + content: 'original content', + deleted: false, + tempFile: false, + }), + ); + }); - localState.stagedFiles.push(f); - localState.changedFiles.push(f); - localState.entries[f.path] = f; - }); + it('merges deleted and added file into a changed file if the contents differ', () => { + stagedFile.deleted = true; + unstagedFile.tempFile = true; + unstagedFile.content = 'hello'; - it('removes from stagedFiles array', () => { - mutations.UNSTAGE_CHANGE(localState, f.path); + mutation(localState, { + path: unstagedFile.path, + diffInfo: localStore.getters.getDiffInfo(unstagedFile.path), + }); - expect(localState.stagedFiles.length).toBe(0); - expect(localState.changedFiles.length).toBe(1); - }); - }); + expect(localState.stagedFiles.length).toBe(stagedFilesCount); + expect(localState.changedFiles.length).toBe(changedFilesCount); + + expect(unstagedFile).toEqual( + expect.objectContaining({ + content: 'hello', + staged, + deleted: false, + tempFile: false, + changed: true, + }), + ); + }); + + it('does not remove file from stagedFiles and changedFiles if the file was renamed, even if the contents are equal', () => { + unstagedFile.content = 'original content'; + unstagedFile.prevPath = 'old_file'; + + mutation(localState, { + path: unstagedFile.path, + diffInfo: localStore.getters.getDiffInfo(unstagedFile.path), + }); + + expect(localState.entries.file).toEqual( + expect.objectContaining({ + content: 'original content', + staged, + changed: false, + prevPath: 'old_file', + }), + ); + + expect(localState.stagedFiles.length).toBe(stagedFilesCount); + expect(localState.changedFiles.length).toBe(changedFilesCount); + }); + + it(`removes file from ${removedFrom} array and adds it into ${addedTo} array`, () => { + localState.stagedFiles.length = 0; + + mutation(localState, { + path: unstagedFile.path, + diffInfo: localStore.getters.getDiffInfo(unstagedFile.path), + }); + + expect(localState.stagedFiles.length).toBe(stagedFilesCount); + expect(localState.changedFiles.length).toBe(changedFilesCount); + + const f = localState.stagedFiles[0] || localState.changedFiles[0]; + expect(f).toEqual(unstagedFile); + }); + + it(`updates file in ${addedTo} array if it is was already present in it`, () => { + unstagedFile.raw = 'testing 123'; + + mutation(localState, { + path: unstagedFile.path, + diffInfo: localStore.getters.getDiffInfo(unstagedFile.path), + }); + + expect(localState.stagedFiles.length).toBe(stagedFilesCount); + expect(localState.changedFiles.length).toBe(changedFilesCount); + + const f = localState.stagedFiles[0] || localState.changedFiles[0]; + expect(f.raw).toEqual('testing 123'); + }); + }, + ); describe('TOGGLE_FILE_CHANGED', () => { it('updates file changed status', () => { diff --git a/spec/javascripts/image_diff/helpers/init_image_diff_spec.js b/spec/frontend/image_diff/helpers/init_image_diff_spec.js similarity index 90% rename from spec/javascripts/image_diff/helpers/init_image_diff_spec.js rename to spec/frontend/image_diff/helpers/init_image_diff_spec.js index ba501d58965ff9e71b86480e7ac182a71a21fd55..dc872ace265dc09a31213f70c42dafc50a764446 100644 --- a/spec/javascripts/image_diff/helpers/init_image_diff_spec.js +++ b/spec/frontend/image_diff/helpers/init_image_diff_spec.js @@ -14,8 +14,8 @@ describe('initImageDiff', () => { <div class="diff-file"></div> `; - spyOn(ReplacedImageDiff.prototype, 'init').and.callFake(() => {}); - spyOn(ImageDiff.prototype, 'init').and.callFake(() => {}); + jest.spyOn(ReplacedImageDiff.prototype, 'init').mockImplementation(() => {}); + jest.spyOn(ImageDiff.prototype, 'init').mockImplementation(() => {}); }); afterEach(() => { diff --git a/spec/javascripts/image_diff/init_discussion_tab_spec.js b/spec/frontend/image_diff/init_discussion_tab_spec.js similarity index 60% rename from spec/javascripts/image_diff/init_discussion_tab_spec.js rename to spec/frontend/image_diff/init_discussion_tab_spec.js index 5eb87e1df25145e3e1a66f3c1d483241fb8fba93..f459fdf5a08d190bf4f61cfecdbf4f9f9d6091e8 100644 --- a/spec/javascripts/image_diff/init_discussion_tab_spec.js +++ b/spec/frontend/image_diff/init_discussion_tab_spec.js @@ -12,29 +12,31 @@ describe('initDiscussionTab', () => { }); it('should pass canCreateNote as false to initImageDiff', done => { - spyOn(initImageDiffHelper, 'initImageDiff').and.callFake((diffFileEl, canCreateNote) => { - expect(canCreateNote).toEqual(false); - done(); - }); + jest + .spyOn(initImageDiffHelper, 'initImageDiff') + .mockImplementation((diffFileEl, canCreateNote) => { + expect(canCreateNote).toEqual(false); + done(); + }); initDiscussionTab(); }); it('should pass renderCommentBadge as true to initImageDiff', done => { - spyOn(initImageDiffHelper, 'initImageDiff').and.callFake( - (diffFileEl, canCreateNote, renderCommentBadge) => { + jest + .spyOn(initImageDiffHelper, 'initImageDiff') + .mockImplementation((diffFileEl, canCreateNote, renderCommentBadge) => { expect(renderCommentBadge).toEqual(true); done(); - }, - ); + }); initDiscussionTab(); }); it('should call initImageDiff for each diffFileEls', () => { - spyOn(initImageDiffHelper, 'initImageDiff').and.callFake(() => {}); + jest.spyOn(initImageDiffHelper, 'initImageDiff').mockImplementation(() => {}); initDiscussionTab(); - expect(initImageDiffHelper.initImageDiff.calls.count()).toEqual(2); + expect(initImageDiffHelper.initImageDiff.mock.calls.length).toEqual(2); }); }); diff --git a/spec/frontend/import_projects/components/import_projects_table_spec.js b/spec/frontend/import_projects/components/import_projects_table_spec.js index 708f2758083fe496366c05b8750bc5380064683a..deffe22ea772e322f8c86c3391403e740351c480 100644 --- a/spec/frontend/import_projects/components/import_projects_table_spec.js +++ b/spec/frontend/import_projects/components/import_projects_table_spec.js @@ -45,7 +45,6 @@ describe('ImportProjectsTable', () => { propsData: { providerTitle, }, - sync: false, }); return component.vm; diff --git a/spec/frontend/import_projects/components/imported_project_table_row_spec.js b/spec/frontend/import_projects/components/imported_project_table_row_spec.js index 34961eae0f343662e4b615f7ba7dab3635bfa158..700dd1e025a7c636a108ca34014c4d4249cc4f08 100644 --- a/spec/frontend/import_projects/components/imported_project_table_row_spec.js +++ b/spec/frontend/import_projects/components/imported_project_table_row_spec.js @@ -26,7 +26,6 @@ describe('ImportedProjectTableRow', () => { ...project, }, }, - sync: false, }); return component.vm; diff --git a/spec/frontend/import_projects/components/provider_repo_table_row_spec.js b/spec/frontend/import_projects/components/provider_repo_table_row_spec.js index 02c786d8d0bba79b5e9aec55dbb9124aa4dd57f3..8efd526e360eca2cefe2a24df0ac6fdf9b1f4d3b 100644 --- a/spec/frontend/import_projects/components/provider_repo_table_row_spec.js +++ b/spec/frontend/import_projects/components/provider_repo_table_row_spec.js @@ -45,7 +45,6 @@ describe('ProviderRepoTableRow', () => { propsData: { repo, }, - sync: false, }); return component.vm; diff --git a/spec/frontend/issuable_suggestions/components/app_spec.js b/spec/frontend/issuable_suggestions/components/app_spec.js index 4186020275023117a1cf4106a32b49fed230237f..20930be8667d297a810ff0f347f3de89df1383b5 100644 --- a/spec/frontend/issuable_suggestions/components/app_spec.js +++ b/spec/frontend/issuable_suggestions/components/app_spec.js @@ -11,8 +11,6 @@ describe('Issuable suggestions app component', () => { search, projectPath: 'project', }, - sync: false, - attachToDocument: true, }); } @@ -27,7 +25,9 @@ describe('Issuable suggestions app component', () => { it('does not render with empty search', () => { wrapper.setProps({ search: '' }); - expect(wrapper.isVisible()).toBe(false); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.isVisible()).toBe(false); + }); }); describe('with data', () => { @@ -40,14 +40,18 @@ describe('Issuable suggestions app component', () => { it('renders component', () => { wrapper.setData(data); - expect(wrapper.isEmpty()).toBe(false); + return wrapper.vm.$nextTick(() => { + expect(wrapper.isEmpty()).toBe(false); + }); }); it('does not render with empty search', () => { wrapper.setProps({ search: '' }); wrapper.setData(data); - expect(wrapper.isVisible()).toBe(false); + return wrapper.vm.$nextTick(() => { + expect(wrapper.isVisible()).toBe(false); + }); }); it('does not render when loading', () => { @@ -56,13 +60,17 @@ describe('Issuable suggestions app component', () => { loading: 1, }); - expect(wrapper.isVisible()).toBe(false); + return wrapper.vm.$nextTick(() => { + expect(wrapper.isVisible()).toBe(false); + }); }); it('does not render with empty issues data', () => { wrapper.setData({ issues: [] }); - expect(wrapper.isVisible()).toBe(false); + return wrapper.vm.$nextTick(() => { + expect(wrapper.isVisible()).toBe(false); + }); }); it('renders list of issues', () => { diff --git a/spec/frontend/issuable_suggestions/components/item_spec.js b/spec/frontend/issuable_suggestions/components/item_spec.js index 10fba238506c0d9b16bc2e273b3a9c3859d1e8fa..6c3c30fcbb08c1b4e5586baf066608a783912b10 100644 --- a/spec/frontend/issuable_suggestions/components/item_spec.js +++ b/spec/frontend/issuable_suggestions/components/item_spec.js @@ -16,8 +16,6 @@ describe('Issuable suggestions suggestion component', () => { ...suggestion, }, }, - sync: false, - attachToDocument: true, }); } @@ -135,7 +133,7 @@ describe('Issuable suggestions suggestion component', () => { const icon = vm.find(Icon); expect(icon.props('name')).toBe('eye-slash'); - expect(icon.attributes('data-original-title')).toBe('Confidential'); + expect(icon.attributes('title')).toBe('Confidential'); }); }); }); diff --git a/spec/frontend/issuables_list/components/__snapshots__/issuables_list_app_spec.js.snap b/spec/frontend/issuables_list/components/__snapshots__/issuables_list_app_spec.js.snap index f57391a6b0dbba74d5846ee9ac55b63a1baa6c73..3e44531974646f164c78aaed16679488883e7bc8 100644 --- a/spec/frontend/issuables_list/components/__snapshots__/issuables_list_app_spec.js.snap +++ b/spec/frontend/issuables_list/components/__snapshots__/issuables_list_app_spec.js.snap @@ -1,7 +1,7 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Issuables list component with empty issues response with all state should display a catch-all if there are no issues to show 1`] = ` -<glemptystate-stub +<gl-empty-state-stub description="The Issue Tracker is the place to add things that need to be improved or solved in a project. You can register or sign in to create issues for this project." svgpath="/emptySvg" title="There are no issues to show" diff --git a/spec/frontend/issuables_list/components/issuable_spec.js b/spec/frontend/issuables_list/components/issuable_spec.js index b6851a0e24cebccb76ac6d2c5897fef406cea129..81f6b60ae25d7d5488bd6d421a9906a76ab3eee6 100644 --- a/spec/frontend/issuables_list/components/issuable_spec.js +++ b/spec/frontend/issuables_list/components/issuable_spec.js @@ -44,8 +44,6 @@ describe('Issuable component', () => { baseUrl: TEST_BASE_URL, ...props, }, - sync: false, - attachToDocument: true, }); }; @@ -70,7 +68,7 @@ describe('Issuable component', () => { const findTaskStatus = () => wrapper.find('.task-status'); const findOpenedAgoContainer = () => wrapper.find({ ref: 'openedAgoByContainer' }); const findMilestone = () => wrapper.find('.js-milestone'); - const findMilestoneTooltip = () => findMilestone().attributes('data-original-title'); + const findMilestoneTooltip = () => findMilestone().attributes('title'); const findDueDate = () => wrapper.find('.js-due-date'); const findLabelContainer = () => wrapper.find('.js-labels'); const findLabelLinks = () => findLabelContainer().findAll(GlLink); @@ -240,7 +238,7 @@ describe('Issuable component', () => { const labels = findLabelLinks().wrappers.map(label => ({ href: label.attributes('href'), text: label.text(), - tooltip: label.find('span').attributes('data-original-title'), + tooltip: label.find('span').attributes('title'), })); const expected = testLabels.map(label => ({ @@ -339,7 +337,9 @@ describe('Issuable component', () => { findBulkCheckbox().trigger('click'); - expect(wrapper.emitted().select).toEqual([[{ issuable, selected: !selected }]]); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.emitted().select).toEqual([[{ issuable, selected: !selected }]]); + }); }); }); }); diff --git a/spec/frontend/issuables_list/components/issuables_list_app_spec.js b/spec/frontend/issuables_list/components/issuables_list_app_spec.js index 621e8b8aa54416b9fb5b7ab52f2293d1f8f93159..eafc4d83d87e878f23dd91345348b05cdbb8ef11 100644 --- a/spec/frontend/issuables_list/components/issuables_list_app_spec.js +++ b/spec/frontend/issuables_list/components/issuables_list_app_spec.js @@ -1,6 +1,6 @@ import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; -import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; import { GlEmptyState, GlPagination, GlSkeletonLoading } from '@gitlab/ui'; import waitForPromises from 'helpers/wait_for_promises'; import { TEST_HOST } from 'helpers/test_constants'; @@ -18,8 +18,6 @@ const TEST_ENDPOINT = '/issues'; const TEST_CREATE_ISSUES_PATH = '/createIssue'; const TEST_EMPTY_SVG_PATH = '/emptySvg'; -const localVue = createLocalVue(); - const MOCK_ISSUES = Array(PAGE_SIZE_MANUAL) .fill(0) .map((_, i) => ({ @@ -40,16 +38,13 @@ describe('Issuables list component', () => { }; const factory = (props = { sortKey: 'priority' }) => { - wrapper = shallowMount(localVue.extend(IssuablesListApp), { + wrapper = shallowMount(IssuablesListApp, { propsData: { endpoint: TEST_ENDPOINT, createIssuePath: TEST_CREATE_ISSUES_PATH, emptySvgPath: TEST_EMPTY_SVG_PATH, ...props, }, - localVue, - sync: false, - attachToDocument: true, }); }; diff --git a/spec/javascripts/issue_show/components/edit_actions_spec.js b/spec/frontend/issue_show/components/edit_actions_spec.js similarity index 93% rename from spec/javascripts/issue_show/components/edit_actions_spec.js rename to spec/frontend/issue_show/components/edit_actions_spec.js index 2ab74ae4e107261cae34f9d4d7525b51e8ed138c..b0c1894058e37b46b8a5cee199b32dfcd1e46537 100644 --- a/spec/javascripts/issue_show/components/edit_actions_spec.js +++ b/spec/frontend/issue_show/components/edit_actions_spec.js @@ -15,7 +15,7 @@ describe('Edit Actions components', () => { }); store.formState.title = 'test'; - spyOn(eventHub, '$emit'); + jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); vm = new Component({ propsData: { @@ -101,14 +101,14 @@ describe('Edit Actions components', () => { describe('deleteIssuable', () => { it('sends delete.issuable event when clicking save button', () => { - spyOn(window, 'confirm').and.returnValue(true); + jest.spyOn(window, 'confirm').mockReturnValue(true); vm.$el.querySelector('.btn-danger').click(); expect(eventHub.$emit).toHaveBeenCalledWith('delete.issuable', { destroy_confirm: true }); }); it('shows loading icon after clicking delete button', done => { - spyOn(window, 'confirm').and.returnValue(true); + jest.spyOn(window, 'confirm').mockReturnValue(true); vm.$el.querySelector('.btn-danger').click(); Vue.nextTick(() => { @@ -119,7 +119,7 @@ describe('Edit Actions components', () => { }); it('does no actions when confirm is false', done => { - spyOn(window, 'confirm').and.returnValue(false); + jest.spyOn(window, 'confirm').mockReturnValue(false); vm.$el.querySelector('.btn-danger').click(); Vue.nextTick(() => { diff --git a/spec/javascripts/issue_show/components/fields/description_spec.js b/spec/frontend/issue_show/components/fields/description_spec.js similarity index 96% rename from spec/javascripts/issue_show/components/fields/description_spec.js rename to spec/frontend/issue_show/components/fields/description_spec.js index f5f87a6bfbfbe535bc0615da13a246da94b02947..8ea326ad1ee41357accd538618ccd650ed71c8ab 100644 --- a/spec/javascripts/issue_show/components/fields/description_spec.js +++ b/spec/frontend/issue_show/components/fields/description_spec.js @@ -20,7 +20,7 @@ describe('Description field component', () => { document.body.appendChild(el); - spyOn(eventHub, '$emit'); + jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); vm = new Component({ el, diff --git a/spec/javascripts/issue_show/components/fields/title_spec.js b/spec/frontend/issue_show/components/fields/title_spec.js similarity index 95% rename from spec/javascripts/issue_show/components/fields/title_spec.js rename to spec/frontend/issue_show/components/fields/title_spec.js index 62dff983250b889377aa2946ff261fcb10c3f889..99e8658b89f3851b76096bc3289cedcf143f88f6 100644 --- a/spec/javascripts/issue_show/components/fields/title_spec.js +++ b/spec/frontend/issue_show/components/fields/title_spec.js @@ -17,7 +17,7 @@ describe('Title field component', () => { }); store.formState.title = 'test'; - spyOn(eventHub, '$emit'); + jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); vm = new Component({ propsData: { diff --git a/spec/frontend/issue_show/components/pinned_links_spec.js b/spec/frontend/issue_show/components/pinned_links_spec.js index 77da3390918471722cb204be314eb4edb3c3c84b..59c919c85d50d140b8a024c84255597a919da2d3 100644 --- a/spec/frontend/issue_show/components/pinned_links_spec.js +++ b/spec/frontend/issue_show/components/pinned_links_spec.js @@ -1,9 +1,7 @@ -import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; import { GlLink } from '@gitlab/ui'; import PinnedLinks from '~/issue_show/components/pinned_links.vue'; -const localVue = createLocalVue(); - const plainZoomUrl = 'https://zoom.us/j/123456789'; describe('PinnedLinks', () => { @@ -19,9 +17,7 @@ describe('PinnedLinks', () => { }; const createComponent = props => { - wrapper = shallowMount(localVue.extend(PinnedLinks), { - localVue, - sync: false, + wrapper = shallowMount(PinnedLinks, { propsData: { zoomMeetingUrl: null, ...props, diff --git a/spec/javascripts/issue_show/index_spec.js b/spec/frontend/issue_show/index_spec.js similarity index 91% rename from spec/javascripts/issue_show/index_spec.js rename to spec/frontend/issue_show/index_spec.js index fa0b426c06ce482c3922743ccb65ddc9c7650755..e80d1b83c110207a595f823ab9a7401c0f18d26e 100644 --- a/spec/javascripts/issue_show/index_spec.js +++ b/spec/frontend/issue_show/index_spec.js @@ -10,7 +10,7 @@ describe('Issue show index', () => { }); document.body.appendChild(d); - const alertSpy = spyOn(window, 'alert'); + const alertSpy = jest.spyOn(window, 'alert'); initIssueableApp(); expect(alertSpy).not.toHaveBeenCalled(); diff --git a/spec/javascripts/issue_spec.js b/spec/frontend/issue_spec.js similarity index 66% rename from spec/javascripts/issue_spec.js rename to spec/frontend/issue_spec.js index 966aee72abbb23f623fac4a46c1b631d2c6bc28c..586bd7f852968187c4de2886cca82f96e441cf18 100644 --- a/spec/javascripts/issue_spec.js +++ b/spec/frontend/issue_spec.js @@ -6,7 +6,13 @@ import axios from '~/lib/utils/axios_utils'; import Issue from '~/issue'; import '~/lib/utils/text_utility'; -describe('Issue', function() { +describe('Issue', () => { + let testContext; + + beforeEach(() => { + testContext = {}; + }); + let $boxClosed, $boxOpen, $btn; preloadFixtures('issues/closed-issue.html'); @@ -80,10 +86,18 @@ describe('Issue', function() { } [true, false].forEach(isIssueInitiallyOpen => { - describe(`with ${isIssueInitiallyOpen ? 'open' : 'closed'} issue`, function() { + describe(`with ${isIssueInitiallyOpen ? 'open' : 'closed'} issue`, () => { const action = isIssueInitiallyOpen ? 'close' : 'reopen'; let mock; + function setup() { + testContext.issue = new Issue(); + expectIssueState(isIssueInitiallyOpen); + + testContext.$projectIssuesCounter = $('.issue_counter').first(); + testContext.$projectIssuesCounter.text('1,001'); + } + function mockCloseButtonResponseSuccess(url, response) { mock.onPut(url).reply(() => { expectNewBranchButtonState(true, false); @@ -103,7 +117,7 @@ describe('Issue', function() { }); } - beforeEach(function() { + beforeEach(() => { if (isIssueInitiallyOpen) { loadFixtures('issues/open-issue.html'); } else { @@ -111,19 +125,11 @@ describe('Issue', function() { } mock = new MockAdapter(axios); - mock.onGet(/(.*)\/related_branches$/).reply(200, {}); + jest.spyOn(axios, 'get'); findElements(isIssueInitiallyOpen); - this.issue = new Issue(); - expectIssueState(isIssueInitiallyOpen); - - this.$triggeredButton = $btn; - - this.$projectIssuesCounter = $('.issue_counter').first(); - this.$projectIssuesCounter.text('1,001'); - - spyOn(axios, 'get').and.callThrough(); + testContext.$triggeredButton = $btn; }); afterEach(() => { @@ -131,82 +137,90 @@ describe('Issue', function() { $('div.flash-alert').remove(); }); - it(`${action}s the issue`, function(done) { - mockCloseButtonResponseSuccess(this.$triggeredButton.attr('href'), { + it(`${action}s the issue`, done => { + mockCloseButtonResponseSuccess(testContext.$triggeredButton.attr('href'), { id: 34, }); mockCanCreateBranch(!isIssueInitiallyOpen); - this.$triggeredButton.trigger('click'); + setup(); + testContext.$triggeredButton.trigger('click'); - setTimeout(() => { + setImmediate(() => { expectIssueState(!isIssueInitiallyOpen); - expect(this.$triggeredButton.get(0).getAttribute('disabled')).toBeNull(); - expect(this.$projectIssuesCounter.text()).toBe(isIssueInitiallyOpen ? '1,000' : '1,002'); + expect(testContext.$triggeredButton.get(0).getAttribute('disabled')).toBeNull(); + expect(testContext.$projectIssuesCounter.text()).toBe( + isIssueInitiallyOpen ? '1,000' : '1,002', + ); expectNewBranchButtonState(false, !isIssueInitiallyOpen); done(); }); }); - it(`fails to ${action} the issue if saved:false`, function(done) { - mockCloseButtonResponseSuccess(this.$triggeredButton.attr('href'), { + it(`fails to ${action} the issue if saved:false`, done => { + mockCloseButtonResponseSuccess(testContext.$triggeredButton.attr('href'), { saved: false, }); mockCanCreateBranch(isIssueInitiallyOpen); - this.$triggeredButton.trigger('click'); + setup(); + testContext.$triggeredButton.trigger('click'); - setTimeout(() => { + setImmediate(() => { expectIssueState(isIssueInitiallyOpen); - expect(this.$triggeredButton.get(0).getAttribute('disabled')).toBeNull(); + expect(testContext.$triggeredButton.get(0).getAttribute('disabled')).toBeNull(); expectErrorMessage(); - expect(this.$projectIssuesCounter.text()).toBe('1,001'); + expect(testContext.$projectIssuesCounter.text()).toBe('1,001'); expectNewBranchButtonState(false, isIssueInitiallyOpen); done(); }); }); - it(`fails to ${action} the issue if HTTP error occurs`, function(done) { - mockCloseButtonResponseError(this.$triggeredButton.attr('href')); + it(`fails to ${action} the issue if HTTP error occurs`, done => { + mockCloseButtonResponseError(testContext.$triggeredButton.attr('href')); mockCanCreateBranch(isIssueInitiallyOpen); - this.$triggeredButton.trigger('click'); + setup(); + testContext.$triggeredButton.trigger('click'); - setTimeout(() => { + setImmediate(() => { expectIssueState(isIssueInitiallyOpen); - expect(this.$triggeredButton.get(0).getAttribute('disabled')).toBeNull(); + expect(testContext.$triggeredButton.get(0).getAttribute('disabled')).toBeNull(); expectErrorMessage(); - expect(this.$projectIssuesCounter.text()).toBe('1,001'); + expect(testContext.$projectIssuesCounter.text()).toBe('1,001'); expectNewBranchButtonState(false, isIssueInitiallyOpen); done(); }); }); - it('disables the new branch button if Ajax call fails', function() { - mockCloseButtonResponseError(this.$triggeredButton.attr('href')); + it('disables the new branch button if Ajax call fails', () => { + mockCloseButtonResponseError(testContext.$triggeredButton.attr('href')); mock.onGet(/(.*)\/can_create_branch$/).networkError(); - this.$triggeredButton.trigger('click'); + setup(); + testContext.$triggeredButton.trigger('click'); expectNewBranchButtonState(false, false); }); - it('does not trigger Ajax call if new branch button is missing', function(done) { - mockCloseButtonResponseError(this.$triggeredButton.attr('href')); - Issue.$btnNewBranch = $(); - this.canCreateBranchDeferred = null; + it('does not trigger Ajax call if new branch button is missing', done => { + mockCloseButtonResponseError(testContext.$triggeredButton.attr('href')); + + document.querySelector('#related-branches').remove(); + document.querySelector('.create-mr-dropdown-wrap').remove(); - this.$triggeredButton.trigger('click'); + setup(); + testContext.$triggeredButton.trigger('click'); - setTimeout(() => { + setImmediate(() => { expect(axios.get).not.toHaveBeenCalled(); done(); diff --git a/spec/frontend/jobs/components/erased_block_spec.js b/spec/frontend/jobs/components/erased_block_spec.js index c7a53197fade27a01710083ab945a6fb6b57f5db..d66ee71df6a4a65df7743403870a40260526cccc 100644 --- a/spec/frontend/jobs/components/erased_block_spec.js +++ b/spec/frontend/jobs/components/erased_block_spec.js @@ -13,8 +13,6 @@ describe('Erased block', () => { const createComponent = props => { wrapper = mount(ErasedBlock, { propsData: props, - sync: false, - attachToDocument: true, }); }; diff --git a/spec/javascripts/jobs/components/job_log_controllers_spec.js b/spec/frontend/jobs/components/job_log_controllers_spec.js similarity index 96% rename from spec/javascripts/jobs/components/job_log_controllers_spec.js rename to spec/frontend/jobs/components/job_log_controllers_spec.js index d527c6708fcec57a3ea33ea6966b55eb09336cf7..04f2081160193608c0b9449fca44c23848735d88 100644 --- a/spec/javascripts/jobs/components/job_log_controllers_spec.js +++ b/spec/frontend/jobs/components/job_log_controllers_spec.js @@ -100,7 +100,7 @@ describe('Job log controllers', () => { }); it('emits scrollJobLogTop event on click', () => { - spyOn(vm, '$emit'); + jest.spyOn(vm, '$emit').mockImplementation(() => {}); vm.$el.querySelector('.js-scroll-top').click(); expect(vm.$emit).toHaveBeenCalledWith('scrollJobLogTop'); @@ -127,7 +127,7 @@ describe('Job log controllers', () => { }); it('does not emit scrollJobLogTop event on click', () => { - spyOn(vm, '$emit'); + jest.spyOn(vm, '$emit').mockImplementation(() => {}); vm.$el.querySelector('.js-scroll-top').click(); expect(vm.$emit).not.toHaveBeenCalledWith('scrollJobLogTop'); @@ -146,7 +146,7 @@ describe('Job log controllers', () => { }); it('emits scrollJobLogBottom event on click', () => { - spyOn(vm, '$emit'); + jest.spyOn(vm, '$emit').mockImplementation(() => {}); vm.$el.querySelector('.js-scroll-bottom').click(); expect(vm.$emit).toHaveBeenCalledWith('scrollJobLogBottom'); @@ -173,7 +173,7 @@ describe('Job log controllers', () => { }); it('does not emit scrollJobLogBottom event on click', () => { - spyOn(vm, '$emit'); + jest.spyOn(vm, '$emit').mockImplementation(() => {}); vm.$el.querySelector('.js-scroll-bottom').click(); expect(vm.$emit).not.toHaveBeenCalledWith('scrollJobLogBottom'); diff --git a/spec/frontend/jobs/components/log/collapsible_section_spec.js b/spec/frontend/jobs/components/log/collapsible_section_spec.js index 01184a51193415e0bffee26b9f08b6a77174dd41..3a16521a986b56a7d6fb26a48b5618473e4ed895 100644 --- a/spec/frontend/jobs/components/log/collapsible_section_spec.js +++ b/spec/frontend/jobs/components/log/collapsible_section_spec.js @@ -12,7 +12,6 @@ describe('Job Log Collapsible Section', () => { const createComponent = (props = {}) => { wrapper = mount(CollpasibleSection, { - sync: true, propsData: { ...props, }, @@ -68,6 +67,9 @@ describe('Job Log Collapsible Section', () => { }); findCollapsibleLine().trigger('click'); - expect(wrapper.emitted('onClickCollapsibleLine').length).toBe(1); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.emitted('onClickCollapsibleLine').length).toBe(1); + }); }); }); diff --git a/spec/frontend/jobs/components/log/duration_badge_spec.js b/spec/frontend/jobs/components/log/duration_badge_spec.js index 2ac34e789096c9519af6c56ca9030b3425af196e..84dae386bdbec4fb6c0913fcbe3041a00cc01bb0 100644 --- a/spec/frontend/jobs/components/log/duration_badge_spec.js +++ b/spec/frontend/jobs/components/log/duration_badge_spec.js @@ -10,7 +10,6 @@ describe('Job Log Duration Badge', () => { const createComponent = (props = {}) => { wrapper = shallowMount(DurationBadge, { - sync: false, propsData: { ...props, }, diff --git a/spec/frontend/jobs/components/log/line_header_spec.js b/spec/frontend/jobs/components/log/line_header_spec.js index 2d2f92fad9de84db32f7448eab77f71a5ee6a4a4..f2e202674eebb27fbf0d9f5e01cf1a4dea3327dc 100644 --- a/spec/frontend/jobs/components/log/line_header_spec.js +++ b/spec/frontend/jobs/components/log/line_header_spec.js @@ -22,7 +22,6 @@ describe('Job Log Header Line', () => { const createComponent = (props = {}) => { wrapper = mount(LineHeader, { - sync: false, propsData: { ...props, }, @@ -79,7 +78,9 @@ describe('Job Log Header Line', () => { it('emits toggleLine event', () => { wrapper.trigger('click'); - expect(wrapper.emitted().toggleLine.length).toBe(1); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.emitted().toggleLine.length).toBe(1); + }); }); }); diff --git a/spec/frontend/jobs/components/log/line_number_spec.js b/spec/frontend/jobs/components/log/line_number_spec.js index fcf2edf91590cabfe234d72fe58ad2ff3fcd4a37..96aa31baab91d8ffcf8c88e659bba1182635cc92 100644 --- a/spec/frontend/jobs/components/log/line_number_spec.js +++ b/spec/frontend/jobs/components/log/line_number_spec.js @@ -11,7 +11,6 @@ describe('Job Log Line Number', () => { const createComponent = (props = {}) => { wrapper = shallowMount(LineNumber, { - sync: false, propsData: { ...props, }, diff --git a/spec/frontend/jobs/components/log/line_spec.js b/spec/frontend/jobs/components/log/line_spec.js index ea593e3c39a38edf270d0fb399750dff6052d8ff..ec3a3968f142b9df8fc33ea6520d68136131ec8c 100644 --- a/spec/frontend/jobs/components/log/line_spec.js +++ b/spec/frontend/jobs/components/log/line_spec.js @@ -20,7 +20,6 @@ describe('Job Log Line', () => { const createComponent = (props = {}) => { wrapper = shallowMount(Line, { - sync: false, propsData: { ...props, }, diff --git a/spec/frontend/jobs/components/log/log_spec.js b/spec/frontend/jobs/components/log/log_spec.js index 7c834542a9aa846ab889970bf23ca6c45a6c703d..02cdb31d27e8e7299ef72794df996b78cfc45ae4 100644 --- a/spec/frontend/jobs/components/log/log_spec.js +++ b/spec/frontend/jobs/components/log/log_spec.js @@ -15,7 +15,6 @@ describe('Job Log', () => { const createComponent = () => { wrapper = mount(Log, { - sync: false, localVue, store, }); diff --git a/spec/frontend/jobs/components/log/mock_data.js b/spec/frontend/jobs/components/log/mock_data.js index 01f69e6328cb6b3f1c9351249512a9625bf4293f..587818045ebf94037e9f8d7c91a530c8e9470920 100644 --- a/spec/frontend/jobs/components/log/mock_data.js +++ b/spec/frontend/jobs/components/log/mock_data.js @@ -34,7 +34,7 @@ export const utilsMockData = [ content: [ { text: - 'Using Docker executor with image dev.gitlab.org:5005/gitlab/gitlab-build-images:ruby-2.6.3-golang-1.12-git-2.24-lfs-2.9-chrome-73.0-node-12.x-yarn-1.16-postgresql-9.6-graphicsmagick-1.3.33', + 'Using Docker executor with image dev.gitlab.org:5005/gitlab/gitlab-build-images:ruby-2.6.5-golang-1.12-git-2.24-lfs-2.9-chrome-73.0-node-12.x-yarn-1.16-postgresql-9.6-graphicsmagick-1.3.33', }, ], section: 'prepare-executor', diff --git a/spec/frontend/lib/utils/datetime_utility_spec.js b/spec/frontend/lib/utils/datetime_utility_spec.js index 872779299d2a9bad7c8167fcaf197d0e7ef2b8df..e584150ba7095adf549e3b54efdddf8984a8ee19 100644 --- a/spec/frontend/lib/utils/datetime_utility_spec.js +++ b/spec/frontend/lib/utils/datetime_utility_spec.js @@ -341,6 +341,16 @@ describe('prettyTime methods', () => { assertTimeUnits(twoDays, 3, 48, 0, 0); }); + + it('should correctly parse values when limitedToDays is true', () => { + const sevenDays = datetimeUtility.parseSeconds(648750, { + hoursPerDay: 24, + daysPerWeek: 7, + limitToDays: true, + }); + + assertTimeUnits(sevenDays, 12, 12, 7, 0); + }); }); describe('stringifyTime', () => { @@ -445,6 +455,23 @@ describe('getDateInPast', () => { }); }); +describe('getDateInFuture', () => { + const date = new Date('2019-07-16T00:00:00.000Z'); + const daysInFuture = 90; + + it('returns the correct date in the future', () => { + const dateInFuture = datetimeUtility.getDateInFuture(date, daysInFuture); + const expectedDateInFuture = new Date('2019-10-14T00:00:00.000Z'); + + expect(dateInFuture).toStrictEqual(expectedDateInFuture); + }); + + it('does not modifiy the original date', () => { + datetimeUtility.getDateInFuture(date, daysInFuture); + expect(date).toStrictEqual(new Date('2019-07-16T00:00:00.000Z')); + }); +}); + describe('getDatesInRange', () => { it('returns an empty array if 1st or 2nd argument is not a Date object', () => { const d1 = new Date('2019-01-01'); @@ -507,3 +534,32 @@ describe('secondsToDays', () => { expect(datetimeUtility.secondsToDays(270000)).toBe(3); }); }); + +describe('approximateDuration', () => { + it.each` + seconds + ${null} + ${{}} + ${[]} + ${-1} + `('returns a blank string for seconds=$seconds', ({ seconds }) => { + expect(datetimeUtility.approximateDuration(seconds)).toBe(''); + }); + + it.each` + seconds | approximation + ${0} | ${'less than a minute'} + ${25} | ${'less than a minute'} + ${45} | ${'1 minute'} + ${90} | ${'1 minute'} + ${100} | ${'1 minute'} + ${150} | ${'2 minutes'} + ${220} | ${'3 minutes'} + ${3000} | ${'about 1 hour'} + ${30000} | ${'about 8 hours'} + ${100000} | ${'1 day'} + ${180000} | ${'2 days'} + `('converts $seconds seconds to $approximation', ({ seconds, approximation }) => { + expect(datetimeUtility.approximateDuration(seconds)).toBe(approximation); + }); +}); diff --git a/spec/frontend/lib/utils/poll_until_complete_spec.js b/spec/frontend/lib/utils/poll_until_complete_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..15602b87b9c9315ff6fe492b145687dae4fb8be9 --- /dev/null +++ b/spec/frontend/lib/utils/poll_until_complete_spec.js @@ -0,0 +1,89 @@ +import AxiosMockAdapter from 'axios-mock-adapter'; +import axios from '~/lib/utils/axios_utils'; +import pollUntilComplete from '~/lib/utils/poll_until_complete'; +import httpStatusCodes from '~/lib/utils/http_status'; +import { TEST_HOST } from 'helpers/test_constants'; + +const endpoint = `${TEST_HOST}/foo`; +const mockData = 'mockData'; +const pollInterval = 1234; +const pollIntervalHeader = { + 'Poll-Interval': pollInterval, +}; + +describe('pollUntilComplete', () => { + let mock; + + beforeEach(() => { + mock = new AxiosMockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + }); + + describe('given an immediate success response', () => { + beforeEach(() => { + mock.onGet(endpoint).replyOnce(httpStatusCodes.OK, mockData); + }); + + it('resolves with the response', () => + pollUntilComplete(endpoint).then(({ data }) => { + expect(data).toBe(mockData); + })); + }); + + describe(`given the endpoint returns NO_CONTENT with a Poll-Interval before succeeding`, () => { + beforeEach(() => { + mock + .onGet(endpoint) + .replyOnce(httpStatusCodes.NO_CONTENT, undefined, pollIntervalHeader) + .onGet(endpoint) + .replyOnce(httpStatusCodes.OK, mockData); + }); + + it('calls the endpoint until it succeeds, and resolves with the response', () => + Promise.all([ + pollUntilComplete(endpoint).then(({ data }) => { + expect(data).toBe(mockData); + expect(mock.history.get).toHaveLength(2); + }), + + // To ensure the above pollUntilComplete() promise is actually + // fulfilled, we must explictly run the timers forward by the time + // indicated in the headers *after* each previous request has been + // fulfilled. + axios + // wait for initial NO_CONTENT response to be fulfilled + .waitForAll() + .then(() => { + jest.advanceTimersByTime(pollInterval); + }), + ])); + }); + + describe('given the endpoint returns an error status', () => { + const errorMessage = 'error message'; + + beforeEach(() => { + mock.onGet(endpoint).replyOnce(httpStatusCodes.NOT_FOUND, errorMessage); + }); + + it('rejects with the error response', () => + pollUntilComplete(endpoint).catch(error => { + expect(error.response.data).toBe(errorMessage); + })); + }); + + describe('given params', () => { + const params = { foo: 'bar' }; + beforeEach(() => { + mock.onGet(endpoint, { params }).replyOnce(httpStatusCodes.OK, mockData); + }); + + it('requests the expected URL', () => + pollUntilComplete(endpoint, { params }).then(({ data }) => { + expect(data).toBe(mockData); + })); + }); +}); diff --git a/spec/frontend/lib/utils/text_utility_spec.js b/spec/frontend/lib/utils/text_utility_spec.js index deb6dab772ecf3900d28671fcc4ffa8d1e166d90..803b3629524bc4f6a3462da48633cae0ec22449e 100644 --- a/spec/frontend/lib/utils/text_utility_spec.js +++ b/spec/frontend/lib/utils/text_utility_spec.js @@ -27,6 +27,9 @@ describe('text_utility', () => { it('should remove underscores and uppercase the first letter', () => { expect(textUtils.humanize('foo_bar')).toEqual('Foo bar'); }); + it('should remove underscores and dashes and uppercase the first letter', () => { + expect(textUtils.humanize('foo_bar-foo', '[_-]')).toEqual('Foo bar foo'); + }); }); describe('dasherize', () => { @@ -52,14 +55,20 @@ describe('text_utility', () => { expect(textUtils.slugify(' a new project ')).toEqual('a-new-project'); }); it('should only remove non-allowed special characters', () => { - expect(textUtils.slugify('test!_pro-ject~')).toEqual('test-_pro-ject-'); + expect(textUtils.slugify('test!_pro-ject~')).toEqual('test-_pro-ject'); }); it('should squash multiple hypens', () => { - expect(textUtils.slugify('test!!!!_pro-ject~')).toEqual('test-_pro-ject-'); + expect(textUtils.slugify('test!!!!_pro-ject~')).toEqual('test-_pro-ject'); }); it('should return empty string if only non-allowed characters', () => { expect(textUtils.slugify('здраÑти')).toEqual(''); }); + it('should squash multiple separators', () => { + expect(textUtils.slugify('Test:-)')).toEqual('test'); + }); + it('should trim any separators from the beginning and end of the slug', () => { + expect(textUtils.slugify('-Test:-)-')).toEqual('test'); + }); }); describe('stripHtml', () => { @@ -109,6 +118,12 @@ describe('text_utility', () => { }); }); + describe('convertToTitleCase', () => { + it('converts sentence case to Sentence Case', () => { + expect(textUtils.convertToTitleCase('hello world')).toBe('Hello World'); + }); + }); + describe('truncateSha', () => { it('shortens SHAs to 8 characters', () => { expect(textUtils.truncateSha('verylongsha')).toBe('verylong'); diff --git a/spec/frontend/monitoring/components/__snapshots__/empty_state_spec.js.snap b/spec/frontend/monitoring/components/__snapshots__/empty_state_spec.js.snap index 5f24bab600cdec0e798e647b14a77b62c5aa5d29..31b3ad1bd76abedc1e11cf9428d5349a2f842f2b 100644 --- a/spec/frontend/monitoring/components/__snapshots__/empty_state_spec.js.snap +++ b/spec/frontend/monitoring/components/__snapshots__/empty_state_spec.js.snap @@ -1,7 +1,7 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`EmptyState shows gettingStarted state 1`] = ` -<glemptystate-stub +<gl-empty-state-stub description="Stay updated about the performance and health of your environment by configuring Prometheus to monitor your deployments." primarybuttonlink="/clustersPath" primarybuttontext="Install on clusters" @@ -13,7 +13,7 @@ exports[`EmptyState shows gettingStarted state 1`] = ` `; exports[`EmptyState shows loading state 1`] = ` -<glemptystate-stub +<gl-empty-state-stub description="Creating graphs uses the data from the Prometheus server. If this takes a long time, ensure that data is available." primarybuttonlink="/documentationPath" primarybuttontext="View documentation" @@ -25,7 +25,7 @@ exports[`EmptyState shows loading state 1`] = ` `; exports[`EmptyState shows unableToConnect state 1`] = ` -<glemptystate-stub +<gl-empty-state-stub description="Ensure connectivity is available from the GitLab server to the Prometheus server" primarybuttonlink="/documentationPath" primarybuttontext="View documentation" diff --git a/spec/frontend/monitoring/components/__snapshots__/group_empty_state_spec.js.snap b/spec/frontend/monitoring/components/__snapshots__/group_empty_state_spec.js.snap index 7f37a83d291fc43a8e09950a1d189dec864a49e3..c30fb572826b7b5338f27515e7f43fb8daba8488 100644 --- a/spec/frontend/monitoring/components/__snapshots__/group_empty_state_spec.js.snap +++ b/spec/frontend/monitoring/components/__snapshots__/group_empty_state_spec.js.snap @@ -1,7 +1,7 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`GroupEmptyState Renders an empty state for BAD_QUERY 1`] = ` -<glemptystate-stub +<gl-empty-state-stub compact="true" primarybuttonlink="/path/to/settings" primarybuttontext="Verify configuration" @@ -13,7 +13,7 @@ exports[`GroupEmptyState Renders an empty state for BAD_QUERY 1`] = ` exports[`GroupEmptyState Renders an empty state for BAD_QUERY 2`] = `"The Prometheus server responded with \\"bad request\\". Please check your queries are correct and are supported in your Prometheus version. <a href=\\"/path/to/docs\\">More information</a>"`; exports[`GroupEmptyState Renders an empty state for CONNECTION_FAILED 1`] = ` -<glemptystate-stub +<gl-empty-state-stub compact="true" description="We couldn't reach the Prometheus server. Either the server no longer exists or the configuration details need updating." primarybuttonlink="/path/to/settings" @@ -26,7 +26,7 @@ exports[`GroupEmptyState Renders an empty state for CONNECTION_FAILED 1`] = ` exports[`GroupEmptyState Renders an empty state for CONNECTION_FAILED 2`] = `undefined`; exports[`GroupEmptyState Renders an empty state for FOO STATE 1`] = ` -<glemptystate-stub +<gl-empty-state-stub compact="true" description="An error occurred while loading the data. Please try again." svgpath="/path/to/empty-group-illustration.svg" @@ -37,7 +37,7 @@ exports[`GroupEmptyState Renders an empty state for FOO STATE 1`] = ` exports[`GroupEmptyState Renders an empty state for FOO STATE 2`] = `undefined`; exports[`GroupEmptyState Renders an empty state for LOADING 1`] = ` -<glemptystate-stub +<gl-empty-state-stub compact="true" description="Creating graphs uses the data from the Prometheus server. If this takes a long time, ensure that data is available." svgpath="/path/to/empty-group-illustration.svg" @@ -48,7 +48,7 @@ exports[`GroupEmptyState Renders an empty state for LOADING 1`] = ` exports[`GroupEmptyState Renders an empty state for LOADING 2`] = `undefined`; exports[`GroupEmptyState Renders an empty state for NO_DATA 1`] = ` -<glemptystate-stub +<gl-empty-state-stub compact="true" svgpath="/path/to/empty-group-illustration.svg" title="No data to display" @@ -58,7 +58,7 @@ exports[`GroupEmptyState Renders an empty state for NO_DATA 1`] = ` exports[`GroupEmptyState Renders an empty state for NO_DATA 2`] = `"The data source is connected, but there is no data to display. <a href=\\"/path/to/docs\\">More information</a>"`; exports[`GroupEmptyState Renders an empty state for TIMEOUT 1`] = ` -<glemptystate-stub +<gl-empty-state-stub compact="true" svgpath="/path/to/empty-group-illustration.svg" title="Connection timed out" @@ -68,7 +68,7 @@ exports[`GroupEmptyState Renders an empty state for TIMEOUT 1`] = ` exports[`GroupEmptyState Renders an empty state for TIMEOUT 2`] = `"Charts can't be displayed as the request for data has timed out. <a href=\\"/path/to/docs\\">More information</a>"`; exports[`GroupEmptyState Renders an empty state for UNKNOWN_ERROR 1`] = ` -<glemptystate-stub +<gl-empty-state-stub compact="true" description="An error occurred while loading the data. Please try again." svgpath="/path/to/empty-group-illustration.svg" diff --git a/spec/frontend/monitoring/components/charts/anomaly_spec.js b/spec/frontend/monitoring/components/charts/anomaly_spec.js index 7446461a5742b843e244260e47f31e59e725148e..cea22d075ecb3743c01772e7b8c666cdb8e321ed 100644 --- a/spec/frontend/monitoring/components/charts/anomaly_spec.js +++ b/spec/frontend/monitoring/components/charts/anomaly_spec.js @@ -38,7 +38,6 @@ describe('Anomaly chart component', () => { slots: { default: mockWidgets, }, - sync: false, }); }; const findTimeSeries = () => wrapper.find(MonitorTimeSeriesChart); diff --git a/spec/frontend/monitoring/components/charts/column_spec.js b/spec/frontend/monitoring/components/charts/column_spec.js index b4539801e0f72f922c3fb2752014c2b847f306a7..d6a96ffbd65ba97d441f9be394f50d1b2b99213b 100644 --- a/spec/frontend/monitoring/components/charts/column_spec.js +++ b/spec/frontend/monitoring/components/charts/column_spec.js @@ -1,9 +1,7 @@ -import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; import { GlColumnChart } from '@gitlab/ui/dist/charts'; import ColumnChart from '~/monitoring/components/charts/column.vue'; -const localVue = createLocalVue(); - jest.mock('~/lib/utils/icon_utils', () => ({ getSvgIconPathContent: jest.fn().mockResolvedValue('mockSvgPathContent'), })); @@ -12,7 +10,7 @@ describe('Column component', () => { let columnChart; beforeEach(() => { - columnChart = shallowMount(localVue.extend(ColumnChart), { + columnChart = shallowMount(ColumnChart, { propsData: { graphData: { metrics: [ @@ -34,8 +32,6 @@ describe('Column component', () => { }, containerWidth: 100, }, - sync: false, - localVue, }); }); diff --git a/spec/frontend/monitoring/components/charts/empty_chart_spec.js b/spec/frontend/monitoring/components/charts/empty_chart_spec.js index 06822126b5998d6cc31da608bddf398ab77fc40e..bbfca27dc5a196bbab9d414b6c901d5dd9fd92c0 100644 --- a/spec/frontend/monitoring/components/charts/empty_chart_spec.js +++ b/spec/frontend/monitoring/components/charts/empty_chart_spec.js @@ -1,19 +1,15 @@ -import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; import EmptyChart from '~/monitoring/components/charts/empty_chart.vue'; -const localVue = createLocalVue(); - describe('Empty Chart component', () => { let emptyChart; const graphTitle = 'Memory Usage'; beforeEach(() => { - emptyChart = shallowMount(localVue.extend(EmptyChart), { + emptyChart = shallowMount(EmptyChart, { propsData: { graphTitle, }, - sync: false, - localVue, }); }); diff --git a/spec/frontend/monitoring/components/charts/single_stat_spec.js b/spec/frontend/monitoring/components/charts/single_stat_spec.js index 78bcc40078730d587f0b0c8fda080426b276d9d5..2410dae112b16944f53b3ec345ae4167c4e41f2a 100644 --- a/spec/frontend/monitoring/components/charts/single_stat_spec.js +++ b/spec/frontend/monitoring/components/charts/single_stat_spec.js @@ -1,19 +1,15 @@ -import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; import SingleStatChart from '~/monitoring/components/charts/single_stat.vue'; import { graphDataPrometheusQuery } from '../../mock_data'; -const localVue = createLocalVue(); - describe('Single Stat Chart component', () => { let singleStatChart; beforeEach(() => { - singleStatChart = shallowMount(localVue.extend(SingleStatChart), { + singleStatChart = shallowMount(SingleStatChart, { propsData: { graphData: graphDataPrometheusQuery, }, - sync: false, - localVue, }); }); diff --git a/spec/frontend/monitoring/components/charts/time_series_spec.js b/spec/frontend/monitoring/components/charts/time_series_spec.js index 098b3408e672197191ca85deefa171e4ed49f73e..d9960b3d18ec027ff96fcf61968546cc0b27bfa2 100644 --- a/spec/frontend/monitoring/components/charts/time_series_spec.js +++ b/spec/frontend/monitoring/components/charts/time_series_spec.js @@ -58,9 +58,7 @@ describe('Time series component', () => { slots: { default: mockWidgets, }, - sync: false, store, - attachToDocument: true, }); }); @@ -83,13 +81,17 @@ describe('Time series component', () => { it('allows user to override max value label text using prop', () => { timeSeriesChart.setProps({ legendMaxText: 'legendMaxText' }); - expect(timeSeriesChart.props().legendMaxText).toBe('legendMaxText'); + return timeSeriesChart.vm.$nextTick().then(() => { + expect(timeSeriesChart.props().legendMaxText).toBe('legendMaxText'); + }); }); it('allows user to override average value label text using prop', () => { timeSeriesChart.setProps({ legendAverageText: 'averageText' }); - expect(timeSeriesChart.props().legendAverageText).toBe('averageText'); + return timeSeriesChart.vm.$nextTick().then(() => { + expect(timeSeriesChart.props().legendAverageText).toBe('averageText'); + }); }); describe('methods', () => { @@ -267,7 +269,9 @@ describe('Time series component', () => { option: mockOption, }); - expect(timeSeriesChart.vm.chartOptions).toEqual(expect.objectContaining(mockOption)); + return timeSeriesChart.vm.$nextTick().then(() => { + expect(timeSeriesChart.vm.chartOptions).toEqual(expect.objectContaining(mockOption)); + }); }); it('additional series', () => { @@ -281,10 +285,12 @@ describe('Time series component', () => { }, }); - const optionSeries = timeSeriesChart.vm.chartOptions.series; + return timeSeriesChart.vm.$nextTick().then(() => { + const optionSeries = timeSeriesChart.vm.chartOptions.series; - expect(optionSeries.length).toEqual(2); - expect(optionSeries[0].name).toEqual(mockSeriesName); + expect(optionSeries.length).toEqual(2); + expect(optionSeries[0].name).toEqual(mockSeriesName); + }); }); }); @@ -340,11 +346,10 @@ describe('Time series component', () => { glChartComponents.forEach(dynamicComponent => { describe(`GitLab UI: ${dynamicComponent.chartType}`, () => { let timeSeriesAreaChart; - let glChart; + const findChart = () => timeSeriesAreaChart.find(dynamicComponent.component); beforeEach(done => { timeSeriesAreaChart = makeTimeSeriesChart(mockGraphData, dynamicComponent.chartType); - glChart = timeSeriesAreaChart.find(dynamicComponent.component); timeSeriesAreaChart.vm.$nextTick(done); }); @@ -353,12 +358,12 @@ describe('Time series component', () => { }); it('is a Vue instance', () => { - expect(glChart.exists()).toBe(true); - expect(glChart.isVueInstance()).toBe(true); + expect(findChart().exists()).toBe(true); + expect(findChart().isVueInstance()).toBe(true); }); it('receives data properties needed for proper chart render', () => { - const props = glChart.props(); + const props = findChart().props(); expect(props.data).toBe(timeSeriesAreaChart.vm.chartData); expect(props.option).toBe(timeSeriesAreaChart.vm.chartOptions); @@ -371,7 +376,9 @@ describe('Time series component', () => { timeSeriesAreaChart.vm.tooltip.title = mockTitle; timeSeriesAreaChart.vm.$nextTick(() => { - expect(shallowWrapperContainsSlotText(glChart, 'tooltipTitle', mockTitle)).toBe(true); + expect(shallowWrapperContainsSlotText(findChart(), 'tooltipTitle', mockTitle)).toBe( + true, + ); done(); }); }); @@ -386,7 +393,9 @@ describe('Time series component', () => { }); it('uses deployment title', () => { - expect(shallowWrapperContainsSlotText(glChart, 'tooltipTitle', 'Deployed')).toBe(true); + expect(shallowWrapperContainsSlotText(findChart(), 'tooltipTitle', 'Deployed')).toBe( + true, + ); }); it('renders clickable commit sha in tooltip content', done => { diff --git a/spec/frontend/monitoring/components/dashboard_spec.js b/spec/frontend/monitoring/components/dashboard_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..85408d57dde3f76ccd2631c2c2ebb6635156bda7 --- /dev/null +++ b/spec/frontend/monitoring/components/dashboard_spec.js @@ -0,0 +1,553 @@ +import { shallowMount, createLocalVue, mount } from '@vue/test-utils'; +import { GlDropdownItem, GlButton, GlToast } from '@gitlab/ui'; +import VueDraggable from 'vuedraggable'; +import MockAdapter from 'axios-mock-adapter'; +import axios from '~/lib/utils/axios_utils'; +import statusCodes from '~/lib/utils/http_status'; +import { metricStates } from '~/monitoring/constants'; +import Dashboard from '~/monitoring/components/dashboard.vue'; + +import DashboardsDropdown from '~/monitoring/components/dashboards_dropdown.vue'; +import DateTimePicker from '~/monitoring/components/date_time_picker/date_time_picker.vue'; +import GroupEmptyState from '~/monitoring/components/group_empty_state.vue'; +import { createStore } from '~/monitoring/stores'; +import * as types from '~/monitoring/stores/mutation_types'; +import { setupComponentStore, propsData } from '../init_utils'; +import { + metricsGroupsAPIResponse, + mockedQueryResultPayload, + mockApiEndpoint, + environmentData, + dashboardGitResponse, +} from '../mock_data'; + +const localVue = createLocalVue(); +const expectedPanelCount = 2; + +describe('Dashboard', () => { + let store; + let wrapper; + let mock; + + const createShallowWrapper = (props = {}, options = {}) => { + wrapper = shallowMount(Dashboard, { + localVue, + propsData: { ...propsData, ...props }, + store, + ...options, + }); + }; + + const createMountedWrapper = (props = {}, options = {}) => { + wrapper = mount(Dashboard, { + localVue, + propsData: { ...propsData, ...props }, + store, + ...options, + }); + }; + + beforeEach(() => { + store = createStore(); + mock = new MockAdapter(axios); + }); + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + } + mock.restore(); + }); + + describe('no metrics are available yet', () => { + beforeEach(() => { + mock.onGet(mockApiEndpoint).reply(statusCodes.OK, metricsGroupsAPIResponse); + + createShallowWrapper(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('shows the environment selector', () => { + expect(wrapper.vm.$el.querySelector('.js-environments-dropdown')).toBeTruthy(); + }); + }); + + describe('no data found', () => { + beforeEach(done => { + mock.onGet(mockApiEndpoint).reply(statusCodes.OK, metricsGroupsAPIResponse); + + createShallowWrapper(); + + wrapper.vm.$nextTick(done); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('shows the environment selector dropdown', () => { + expect(wrapper.vm.$el.querySelector('.js-environments-dropdown')).toBeTruthy(); + }); + }); + + describe('request information to the server', () => { + beforeEach(() => { + mock.onGet(mockApiEndpoint).reply(200, metricsGroupsAPIResponse); + }); + + it('shows up a loading state', done => { + createShallowWrapper({ hasMetrics: true }); + + wrapper.vm + .$nextTick() + .then(() => { + expect(wrapper.vm.emptyState).toEqual('loading'); + + done(); + }) + .catch(done.fail); + }); + + it('hides the group panels when showPanels is false', done => { + createMountedWrapper( + { hasMetrics: true, showPanels: false }, + { stubs: ['graph-group', 'panel-type'] }, + ); + + setupComponentStore(wrapper); + + wrapper.vm + .$nextTick() + .then(() => { + expect(wrapper.vm.showEmptyState).toEqual(false); + expect(wrapper.vm.$el.querySelector('.prometheus-panel')).toEqual(null); + // TODO: The last expectation doesn't belong here, it belongs in a `group_group_spec.js` file + // Issue: https://gitlab.com/gitlab-org/gitlab/issues/118780 + // expect(wrapper.vm.$el.querySelector('.prometheus-graph-group')).toBeTruthy(); + + done(); + }) + .catch(done.fail); + }); + + it('fetches the metrics data with proper time window', done => { + jest.spyOn(store, 'dispatch'); + + createMountedWrapper({ hasMetrics: true }, { stubs: ['graph-group', 'panel-type'] }); + + wrapper.vm.$store.commit( + `monitoringDashboard/${types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS}`, + environmentData, + ); + + wrapper.vm + .$nextTick() + .then(() => { + expect(store.dispatch).toHaveBeenCalled(); + + done(); + }) + .catch(done.fail); + }); + }); + + describe('when all requests have been commited by the store', () => { + beforeEach(() => { + mock.onGet(mockApiEndpoint).reply(statusCodes.OK, metricsGroupsAPIResponse); + + createMountedWrapper({ hasMetrics: true }, { stubs: ['graph-group', 'panel-type'] }); + + setupComponentStore(wrapper); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders the environments dropdown with a number of environments', done => { + wrapper.vm + .$nextTick() + .then(() => { + const environmentDropdownItems = wrapper + .find('.js-environments-dropdown') + .findAll(GlDropdownItem); + + expect(wrapper.vm.environments.length).toEqual(environmentData.length); + expect(environmentDropdownItems.length).toEqual(wrapper.vm.environments.length); + + environmentDropdownItems.wrappers.forEach((itemWrapper, index) => { + const anchorEl = itemWrapper.find('a'); + if (anchorEl.exists() && environmentData[index].metrics_path) { + const href = anchorEl.attributes('href'); + expect(href).toBe(environmentData[index].metrics_path); + } + }); + + done(); + }) + .catch(done.fail); + }); + + it('renders the environments dropdown with a single active element', done => { + wrapper.vm + .$nextTick() + .then(() => { + const environmentDropdownItems = wrapper + .find('.js-environments-dropdown') + .findAll(GlDropdownItem); + const activeItem = environmentDropdownItems.wrappers.filter(itemWrapper => + itemWrapper.find('.active').exists(), + ); + + expect(activeItem.length).toBe(1); + done(); + }) + .catch(done.fail); + }); + }); + + it('hides the environments dropdown list when there is no environments', done => { + createMountedWrapper({ hasMetrics: true }, { stubs: ['graph-group', 'panel-type'] }); + + wrapper.vm.$store.commit( + `monitoringDashboard/${types.RECEIVE_METRICS_DATA_SUCCESS}`, + metricsGroupsAPIResponse, + ); + wrapper.vm.$store.commit( + `monitoringDashboard/${types.RECEIVE_METRIC_RESULT_SUCCESS}`, + mockedQueryResultPayload, + ); + + wrapper.vm + .$nextTick() + .then(() => { + const environmentDropdownItems = wrapper + .find('.js-environments-dropdown') + .findAll(GlDropdownItem); + + expect(environmentDropdownItems.length).toEqual(0); + done(); + }) + .catch(done.fail); + }); + + it('renders the datetimepicker dropdown', done => { + createMountedWrapper({ hasMetrics: true }, { stubs: ['graph-group', 'panel-type'] }); + + setupComponentStore(wrapper); + + wrapper.vm + .$nextTick() + .then(() => { + expect(wrapper.find(DateTimePicker).exists()).toBe(true); + done(); + }) + .catch(done.fail); + }); + + describe('when one of the metrics is missing', () => { + beforeEach(done => { + mock.onGet(mockApiEndpoint).reply(200, metricsGroupsAPIResponse); + + createShallowWrapper({ hasMetrics: true }); + setupComponentStore(wrapper); + + wrapper.vm.$nextTick(done); + }); + + it('shows a group empty area', () => { + const emptyGroup = wrapper.findAll({ ref: 'empty-group' }); + + expect(emptyGroup).toHaveLength(1); + expect(emptyGroup.is(GroupEmptyState)).toBe(true); + }); + + it('group empty area displays a NO_DATA state', () => { + expect( + wrapper + .findAll({ ref: 'empty-group' }) + .at(0) + .props('selectedState'), + ).toEqual(metricStates.NO_DATA); + }); + }); + + describe('drag and drop function', () => { + const findDraggables = () => wrapper.findAll(VueDraggable); + const findEnabledDraggables = () => findDraggables().filter(f => !f.attributes('disabled')); + const findDraggablePanels = () => wrapper.findAll('.js-draggable-panel'); + const findRearrangeButton = () => wrapper.find('.js-rearrange-button'); + + beforeEach(() => { + mock.onGet(mockApiEndpoint).reply(statusCodes.OK, metricsGroupsAPIResponse); + }); + + beforeEach(done => { + createShallowWrapper({ hasMetrics: true }); + + setupComponentStore(wrapper); + + wrapper.vm.$nextTick(done); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('wraps vuedraggable', () => { + expect(findDraggablePanels().exists()).toBe(true); + expect(findDraggablePanels().length).toEqual(expectedPanelCount); + }); + + it('is disabled by default', () => { + expect(findRearrangeButton().exists()).toBe(false); + expect(findEnabledDraggables().length).toBe(0); + }); + + describe('when rearrange is enabled', () => { + beforeEach(done => { + wrapper.setProps({ rearrangePanelsAvailable: true }); + wrapper.vm.$nextTick(done); + }); + + it('displays rearrange button', () => { + expect(findRearrangeButton().exists()).toBe(true); + }); + + describe('when rearrange button is clicked', () => { + const findFirstDraggableRemoveButton = () => + findDraggablePanels() + .at(0) + .find('.js-draggable-remove'); + + beforeEach(done => { + findRearrangeButton().vm.$emit('click'); + wrapper.vm.$nextTick(done); + }); + + it('it enables draggables', () => { + expect(findRearrangeButton().attributes('pressed')).toBeTruthy(); + expect(findEnabledDraggables()).toEqual(findDraggables()); + }); + + it('metrics can be swapped', done => { + const firstDraggable = findDraggables().at(0); + const mockMetrics = [...metricsGroupsAPIResponse.panel_groups[1].panels]; + + const firstTitle = mockMetrics[0].title; + const secondTitle = mockMetrics[1].title; + + // swap two elements and `input` them + [mockMetrics[0], mockMetrics[1]] = [mockMetrics[1], mockMetrics[0]]; + firstDraggable.vm.$emit('input', mockMetrics); + + wrapper.vm.$nextTick(() => { + const { panels } = wrapper.vm.dashboard.panel_groups[1]; + + expect(panels[1].title).toEqual(firstTitle); + expect(panels[0].title).toEqual(secondTitle); + done(); + }); + }); + + it('shows a remove button, which removes a panel', done => { + expect(findFirstDraggableRemoveButton().isEmpty()).toBe(false); + + expect(findDraggablePanels().length).toEqual(expectedPanelCount); + findFirstDraggableRemoveButton().trigger('click'); + + wrapper.vm.$nextTick(() => { + expect(findDraggablePanels().length).toEqual(expectedPanelCount - 1); + done(); + }); + }); + + it('it disables draggables when clicked again', done => { + findRearrangeButton().vm.$emit('click'); + wrapper.vm.$nextTick(() => { + expect(findRearrangeButton().attributes('pressed')).toBeFalsy(); + expect(findEnabledDraggables().length).toBe(0); + done(); + }); + }); + }); + }); + }); + + describe('cluster health', () => { + beforeEach(done => { + mock.onGet(propsData.metricsEndpoint).reply(statusCodes.OK, JSON.stringify({})); + createShallowWrapper({ hasMetrics: true }); + + // all_dashboards is not defined in health dashboards + wrapper.vm.$store.commit(`monitoringDashboard/${types.SET_ALL_DASHBOARDS}`, undefined); + wrapper.vm.$nextTick(done); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders correctly', () => { + expect(wrapper.isVueInstance()).toBe(true); + expect(wrapper.exists()).toBe(true); + }); + }); + + describe('dashboard edit link', () => { + const findEditLink = () => wrapper.find('.js-edit-link'); + + beforeEach(done => { + mock.onGet(mockApiEndpoint).reply(statusCodes.OK, metricsGroupsAPIResponse); + + createShallowWrapper({ hasMetrics: true }); + + wrapper.vm.$store.commit( + `monitoringDashboard/${types.SET_ALL_DASHBOARDS}`, + dashboardGitResponse, + ); + wrapper.vm.$nextTick(done); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('is not present for the default dashboard', () => { + expect(findEditLink().exists()).toBe(false); + }); + + it('is present for a custom dashboard, and links to its edit_path', done => { + const dashboard = dashboardGitResponse[1]; // non-default dashboard + const currentDashboard = dashboard.path; + + wrapper.setProps({ currentDashboard }); + wrapper.vm + .$nextTick() + .then(() => { + expect(findEditLink().exists()).toBe(true); + expect(findEditLink().attributes('href')).toBe(dashboard.project_blob_path); + done(); + }) + .catch(done.fail); + }); + }); + + describe('Dashboard dropdown', () => { + beforeEach(() => { + mock.onGet(mockApiEndpoint).reply(200, metricsGroupsAPIResponse); + + createMountedWrapper({ hasMetrics: true }, { stubs: ['graph-group', 'panel-type'] }); + + wrapper.vm.$store.commit( + `monitoringDashboard/${types.SET_ALL_DASHBOARDS}`, + dashboardGitResponse, + ); + }); + + it('shows the dashboard dropdown', done => { + wrapper.vm + .$nextTick() + .then(() => { + const dashboardDropdown = wrapper.find(DashboardsDropdown); + + expect(dashboardDropdown.exists()).toBe(true); + done(); + }) + .catch(done.fail); + }); + }); + + describe('external dashboard link', () => { + beforeEach(() => { + mock.onGet(mockApiEndpoint).reply(200, metricsGroupsAPIResponse); + + createMountedWrapper( + { + hasMetrics: true, + showPanels: false, + showTimeWindowDropdown: false, + externalDashboardUrl: '/mockUrl', + }, + { stubs: ['graph-group', 'panel-type'] }, + ); + }); + + it('shows the link', done => { + wrapper.vm + .$nextTick() + .then(() => { + const externalDashboardButton = wrapper.find('.js-external-dashboard-link'); + + expect(externalDashboardButton.exists()).toBe(true); + expect(externalDashboardButton.is(GlButton)).toBe(true); + expect(externalDashboardButton.text()).toContain('View full dashboard'); + done(); + }) + .catch(done.fail); + }); + }); + + // https://gitlab.com/gitlab-org/gitlab-ce/issues/66922 + // eslint-disable-next-line jest/no-disabled-tests + describe.skip('link to chart', () => { + const currentDashboard = 'TEST_DASHBOARD'; + localVue.use(GlToast); + const link = () => wrapper.find('.js-chart-link'); + const clipboardText = () => link().element.dataset.clipboardText; + + beforeEach(done => { + mock.onGet(mockApiEndpoint).reply(200, metricsGroupsAPIResponse); + + createShallowWrapper({ hasMetrics: true, currentDashboard }); + + setTimeout(done); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('adds a copy button to the dropdown', () => { + expect(link().text()).toContain('Generate link to chart'); + }); + + it('contains a link to the dashboard', () => { + expect(clipboardText()).toContain(`dashboard=${currentDashboard}`); + expect(clipboardText()).toContain(`group=`); + expect(clipboardText()).toContain(`title=`); + expect(clipboardText()).toContain(`y_label=`); + }); + + it('undefined parameter is stripped', done => { + wrapper.setProps({ currentDashboard: undefined }); + + wrapper.vm.$nextTick(() => { + expect(clipboardText()).not.toContain(`dashboard=`); + expect(clipboardText()).toContain(`y_label=`); + done(); + }); + }); + + it('null parameter is stripped', done => { + wrapper.setProps({ currentDashboard: null }); + + wrapper.vm.$nextTick(() => { + expect(clipboardText()).not.toContain(`dashboard=`); + expect(clipboardText()).toContain(`y_label=`); + done(); + }); + }); + + it('creates a toast when clicked', () => { + jest.spyOn(wrapper.vm.$toast, 'show').and.stub(); + + link().vm.$emit('click'); + + expect(wrapper.vm.$toast.show).toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/frontend/monitoring/components/dashboard_time_url_spec.js b/spec/frontend/monitoring/components/dashboard_time_url_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..2da377eb79fd9d6be75f2443207c1182c670df2a --- /dev/null +++ b/spec/frontend/monitoring/components/dashboard_time_url_spec.js @@ -0,0 +1,51 @@ +import { mount } from '@vue/test-utils'; +import createFlash from '~/flash'; +import MockAdapter from 'axios-mock-adapter'; +import Dashboard from '~/monitoring/components/dashboard.vue'; +import { createStore } from '~/monitoring/stores'; +import { propsData } from '../init_utils'; +import axios from '~/lib/utils/axios_utils'; + +jest.mock('~/flash'); + +jest.mock('~/lib/utils/url_utility', () => ({ + getParameterValues: jest.fn().mockReturnValue('<script>alert("XSS")</script>'), +})); + +describe('dashboard invalid url parameters', () => { + let store; + let wrapper; + let mock; + + const createMountedWrapper = (props = {}, options = {}) => { + wrapper = mount(Dashboard, { + propsData: { ...propsData, ...props }, + store, + ...options, + }); + }; + + beforeEach(() => { + store = createStore(); + mock = new MockAdapter(axios); + }); + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + } + mock.restore(); + }); + + it('shows an error message if invalid url parameters are passed', done => { + createMountedWrapper({ hasMetrics: true }, { stubs: ['graph-group', 'panel-type'] }); + + wrapper.vm + .$nextTick() + .then(() => { + expect(createFlash).toHaveBeenCalled(); + done(); + }) + .catch(done.fail); + }); +}); diff --git a/spec/frontend/monitoring/components/dashboard_time_window_spec.js b/spec/frontend/monitoring/components/dashboard_time_window_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..4acc2d75b73499f64281a87e491ed1e1f74a7785 --- /dev/null +++ b/spec/frontend/monitoring/components/dashboard_time_window_spec.js @@ -0,0 +1,68 @@ +import { mount } from '@vue/test-utils'; +import { GlDropdownItem } from '@gitlab/ui'; +import MockAdapter from 'axios-mock-adapter'; +import axios from '~/lib/utils/axios_utils'; +import statusCodes from '~/lib/utils/http_status'; +import Dashboard from '~/monitoring/components/dashboard.vue'; +import { createStore } from '~/monitoring/stores'; +import { propsData, setupComponentStore } from '../init_utils'; +import { metricsGroupsAPIResponse, mockApiEndpoint } from '../mock_data'; + +jest.mock('~/lib/utils/url_utility', () => ({ + getParameterValues: jest.fn().mockImplementation(param => { + if (param === 'start') return ['2019-10-01T18:27:47.000Z']; + if (param === 'end') return ['2019-10-01T18:57:47.000Z']; + return []; + }), + mergeUrlParams: jest.fn().mockReturnValue('#'), +})); + +describe('dashboard time window', () => { + let store; + let wrapper; + let mock; + + const createComponentWrapperMounted = (props = {}, options = {}) => { + wrapper = mount(Dashboard, { + propsData: { ...propsData, ...props }, + store, + ...options, + }); + }; + + beforeEach(() => { + store = createStore(); + mock = new MockAdapter(axios); + }); + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + } + mock.restore(); + }); + + it('shows an error message if invalid url parameters are passed', done => { + mock.onGet(mockApiEndpoint).reply(statusCodes.OK, metricsGroupsAPIResponse); + + createComponentWrapperMounted({ hasMetrics: true }, { stubs: ['graph-group', 'panel-type'] }); + + setupComponentStore(wrapper); + + wrapper.vm + .$nextTick() + .then(() => { + const timeWindowDropdownItems = wrapper + .find('.js-time-window-dropdown') + .findAll(GlDropdownItem); + const activeItem = timeWindowDropdownItems.wrappers.filter(itemWrapper => + itemWrapper.find('.active').exists(), + ); + + expect(activeItem.length).toBe(1); + + done(); + }) + .catch(done.fail); + }); +}); diff --git a/spec/frontend/monitoring/components/dashboards_dropdown_spec.js b/spec/frontend/monitoring/components/dashboards_dropdown_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..6af5ab4ba75f0c608504b924fe07c94b8872e4eb --- /dev/null +++ b/spec/frontend/monitoring/components/dashboards_dropdown_spec.js @@ -0,0 +1,249 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlDropdownItem, GlModal, GlLoadingIcon, GlAlert } from '@gitlab/ui'; +import waitForPromises from 'helpers/wait_for_promises'; + +import DashboardsDropdown from '~/monitoring/components/dashboards_dropdown.vue'; +import DuplicateDashboardForm from '~/monitoring/components/duplicate_dashboard_form.vue'; + +import { dashboardGitResponse } from '../mock_data'; + +const defaultBranch = 'master'; + +function createComponent(props, opts = {}) { + const storeOpts = { + methods: { + duplicateSystemDashboard: jest.fn(), + }, + computed: { + allDashboards: () => dashboardGitResponse, + }, + }; + + return shallowMount(DashboardsDropdown, { + propsData: { + ...props, + defaultBranch, + }, + sync: false, + ...storeOpts, + ...opts, + }); +} + +describe('DashboardsDropdown', () => { + let wrapper; + + const findItems = () => wrapper.findAll(GlDropdownItem); + const findItemAt = i => wrapper.findAll(GlDropdownItem).at(i); + + describe('when it receives dashboards data', () => { + beforeEach(() => { + wrapper = createComponent(); + }); + it('displays an item for each dashboard', () => { + expect(wrapper.findAll(GlDropdownItem).length).toEqual(dashboardGitResponse.length); + }); + + it('displays items with the dashboard display name', () => { + expect(findItemAt(0).text()).toBe(dashboardGitResponse[0].display_name); + expect(findItemAt(1).text()).toBe(dashboardGitResponse[1].display_name); + expect(findItemAt(2).text()).toBe(dashboardGitResponse[2].display_name); + }); + }); + + describe('when a system dashboard is selected', () => { + let duplicateDashboardAction; + let modalDirective; + + beforeEach(() => { + modalDirective = jest.fn(); + duplicateDashboardAction = jest.fn().mockResolvedValue(); + + wrapper = createComponent( + { + selectedDashboard: dashboardGitResponse[0], + }, + { + directives: { + GlModal: modalDirective, + }, + methods: { + // Mock vuex actions + duplicateSystemDashboard: duplicateDashboardAction, + }, + }, + ); + + wrapper.vm.$refs.duplicateDashboardModal.hide = jest.fn(); + }); + + it('displays an item for each dashboard plus a "duplicate dashboard" item', () => { + const item = wrapper.findAll({ ref: 'duplicateDashboardItem' }); + + expect(findItems().length).toEqual(dashboardGitResponse.length + 1); + expect(item.length).toBe(1); + }); + + describe('modal form', () => { + let okEvent; + + const findModal = () => wrapper.find(GlModal); + const findAlert = () => wrapper.find(GlAlert); + + beforeEach(() => { + okEvent = { + preventDefault: jest.fn(), + }; + }); + + it('exists and contains a form to duplicate a dashboard', () => { + expect(findModal().exists()).toBe(true); + expect(findModal().contains(DuplicateDashboardForm)).toBe(true); + }); + + it('saves a new dashboard', done => { + findModal().vm.$emit('ok', okEvent); + + waitForPromises() + .then(() => { + expect(okEvent.preventDefault).toHaveBeenCalled(); + + expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); + expect(wrapper.vm.$refs.duplicateDashboardModal.hide).toHaveBeenCalled(); + expect(wrapper.emitted().selectDashboard).toBeTruthy(); + expect(findAlert().exists()).toBe(false); + done(); + }) + .catch(done.fail); + }); + + describe('when a new dashboard is saved succesfully', () => { + const newDashboard = { + can_edit: true, + default: false, + display_name: 'A new dashboard', + system_dashboard: false, + }; + + const submitForm = formVals => { + duplicateDashboardAction.mockResolvedValueOnce(newDashboard); + findModal() + .find(DuplicateDashboardForm) + .vm.$emit('change', { + dashboard: 'common_metrics.yml', + commitMessage: 'A commit message', + ...formVals, + }); + findModal().vm.$emit('ok', okEvent); + }; + + it('to the default branch, redirects to the new dashboard', done => { + submitForm({ + branch: defaultBranch, + }); + + waitForPromises() + .then(() => { + expect(wrapper.emitted().selectDashboard[0][0]).toEqual(newDashboard); + done(); + }) + .catch(done.fail); + }); + + it('to a new branch refreshes in the current dashboard', done => { + submitForm({ + branch: 'another-branch', + }); + + waitForPromises() + .then(() => { + expect(wrapper.emitted().selectDashboard[0][0]).toEqual(dashboardGitResponse[0]); + done(); + }) + .catch(done.fail); + }); + }); + + it('handles error when a new dashboard is not saved', done => { + const errMsg = 'An error occurred'; + + duplicateDashboardAction.mockRejectedValueOnce(errMsg); + findModal().vm.$emit('ok', okEvent); + + waitForPromises() + .then(() => { + expect(okEvent.preventDefault).toHaveBeenCalled(); + + expect(findAlert().exists()).toBe(true); + expect(findAlert().text()).toBe(errMsg); + + expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); + expect(wrapper.vm.$refs.duplicateDashboardModal.hide).not.toHaveBeenCalled(); + + done(); + }) + .catch(done.fail); + }); + + it('id is correct, as the value of modal directive binding matches modal id', () => { + expect(modalDirective).toHaveBeenCalledTimes(1); + + // Binding's second argument contains the modal id + expect(modalDirective.mock.calls[0][1]).toEqual( + expect.objectContaining({ + value: findModal().props('modalId'), + }), + ); + }); + + it('updates the form on changes', () => { + const formVals = { + dashboard: 'common_metrics.yml', + commitMessage: 'A commit message', + }; + + findModal() + .find(DuplicateDashboardForm) + .vm.$emit('change', formVals); + + // Binding's second argument contains the modal id + expect(wrapper.vm.form).toEqual(formVals); + }); + }); + }); + + describe('when a custom dashboard is selected', () => { + const findModal = () => wrapper.find(GlModal); + + beforeEach(() => { + wrapper = createComponent({ + selectedDashboard: dashboardGitResponse[1], + }); + }); + + it('displays an item for each dashboard', () => { + const item = wrapper.findAll({ ref: 'duplicateDashboardItem' }); + + expect(findItems().length).toEqual(dashboardGitResponse.length); + expect(item.length).toBe(0); + }); + + it('modal form does not exist and contains a form to duplicate a dashboard', () => { + expect(findModal().exists()).toBe(false); + }); + }); + + describe('when a dashboard gets selected by the user', () => { + beforeEach(() => { + wrapper = createComponent(); + findItemAt(1).vm.$emit('click'); + }); + + it('emits a "selectDashboard" event', () => { + expect(wrapper.emitted().selectDashboard).toBeTruthy(); + }); + it('emits a "selectDashboard" event with dashboard information', () => { + expect(wrapper.emitted().selectDashboard[0]).toEqual([dashboardGitResponse[1]]); + }); + }); +}); diff --git a/spec/frontend/monitoring/components/date_time_picker/date_time_picker_input_spec.js b/spec/frontend/monitoring/components/date_time_picker/date_time_picker_input_spec.js index 1315e1226a40af6b762f78319cf639f1540ba9b4..9cac63ad7258842c00afd7014db5f2d82b34fed2 100644 --- a/spec/frontend/monitoring/components/date_time_picker/date_time_picker_input_spec.js +++ b/spec/frontend/monitoring/components/date_time_picker/date_time_picker_input_spec.js @@ -15,7 +15,6 @@ describe('DateTimePickerInput', () => { label: '', ...propsData, }, - sync: false, }); }; @@ -58,8 +57,9 @@ describe('DateTimePickerInput', () => { it('input event is emitted when focus is lost', () => { createComponent(); jest.spyOn(wrapper.vm, '$emit'); - wrapper.find('input').setValue(inputValue); - wrapper.find('input').trigger('blur'); + const input = wrapper.find('input'); + input.setValue(inputValue); + input.trigger('blur'); expect(wrapper.vm.$emit).toHaveBeenCalledWith('input', inputValue); }); diff --git a/spec/frontend/monitoring/components/date_time_picker/date_time_picker_spec.js b/spec/frontend/monitoring/components/date_time_picker/date_time_picker_spec.js index ca05461c8cf34b87b0d7a85484aee726988047a7..180e41861f4f892ff2c071b974b32156ee9649e3 100644 --- a/spec/frontend/monitoring/components/date_time_picker/date_time_picker_spec.js +++ b/spec/frontend/monitoring/components/date_time_picker/date_time_picker_spec.js @@ -3,10 +3,8 @@ import DateTimePicker from '~/monitoring/components/date_time_picker/date_time_p import { timeWindows } from '~/monitoring/constants'; const timeWindowsCount = Object.keys(timeWindows).length; -const selectedTimeWindow = { - start: '2019-10-10T07:00:00.000Z', - end: '2019-10-13T07:00:00.000Z', -}; +const start = '2019-10-10T07:00:00.000Z'; +const end = '2019-10-13T07:00:00.000Z'; const selectedTimeWindowText = `3 days`; describe('DateTimePicker', () => { @@ -18,17 +16,20 @@ describe('DateTimePicker', () => { const cancelButtonElement = () => dateTimePicker.find('button.btn-secondary').element; const fillInputAndBlur = (input, val) => { dateTimePicker.find(input).setValue(val); - dateTimePicker.find(input).trigger('blur'); + return dateTimePicker.vm.$nextTick().then(() => { + dateTimePicker.find(input).trigger('blur'); + return dateTimePicker.vm.$nextTick(); + }); }; const createComponent = props => { dateTimePicker = mount(DateTimePicker, { propsData: { timeWindows, - selectedTimeWindow, + start, + end, ...props, }, - sync: false, }); }; @@ -63,10 +64,8 @@ describe('DateTimePicker', () => { it('renders inputs with h/m/s truncated if its all 0s', done => { createComponent({ - selectedTimeWindow: { - start: '2019-10-10T00:00:00.000Z', - end: '2019-10-14T00:10:00.000Z', - }, + start: '2019-10-10T00:00:00.000Z', + end: '2019-10-14T00:10:00.000Z', }); dateTimePicker.vm.$nextTick(() => { expect(dateTimePicker.find('#custom-time-from').element.value).toBe('2019-10-10'); @@ -95,60 +94,64 @@ describe('DateTimePicker', () => { }); }); - it('renders a disabled apply button on load', () => { - createComponent(); + it('renders a disabled apply button on wrong input', () => { + createComponent({ + start: 'invalid-input-date', + }); expect(applyButtonElement().getAttribute('disabled')).toBe('disabled'); }); it('displays inline error message if custom time range inputs are invalid', done => { createComponent(); - fillInputAndBlur('#custom-time-from', '2019-10-01abc'); - fillInputAndBlur('#custom-time-to', '2019-10-10abc'); - - dateTimePicker.vm.$nextTick(() => { - expect(dateTimePicker.findAll('.invalid-feedback').length).toBe(2); - done(); - }); + fillInputAndBlur('#custom-time-from', '2019-10-01abc') + .then(() => fillInputAndBlur('#custom-time-to', '2019-10-10abc')) + .then(() => { + expect(dateTimePicker.findAll('.invalid-feedback').length).toBe(2); + done(); + }) + .catch(done); }); it('keeps apply button disabled with invalid custom time range inputs', done => { createComponent(); - fillInputAndBlur('#custom-time-from', '2019-10-01abc'); - fillInputAndBlur('#custom-time-to', '2019-09-19'); - - dateTimePicker.vm.$nextTick(() => { - expect(applyButtonElement().getAttribute('disabled')).toBe('disabled'); - done(); - }); + fillInputAndBlur('#custom-time-from', '2019-10-01abc') + .then(() => fillInputAndBlur('#custom-time-to', '2019-09-19')) + .then(() => { + expect(applyButtonElement().getAttribute('disabled')).toBe('disabled'); + done(); + }) + .catch(done); }); it('enables apply button with valid custom time range inputs', done => { createComponent(); - fillInputAndBlur('#custom-time-from', '2019-10-01'); - fillInputAndBlur('#custom-time-to', '2019-10-19'); - - dateTimePicker.vm.$nextTick(() => { - expect(applyButtonElement().getAttribute('disabled')).toBeNull(); - done(); - }); + fillInputAndBlur('#custom-time-from', '2019-10-01') + .then(() => fillInputAndBlur('#custom-time-to', '2019-10-19')) + .then(() => { + expect(applyButtonElement().getAttribute('disabled')).toBeNull(); + done(); + }) + .catch(done.fail); }); - it('returns an object when apply is clicked', done => { + it('emits dates in an object when apply is clicked', done => { createComponent(); - fillInputAndBlur('#custom-time-from', '2019-10-01'); - fillInputAndBlur('#custom-time-to', '2019-10-19'); - - dateTimePicker.vm.$nextTick(() => { - jest.spyOn(dateTimePicker.vm, '$emit'); - applyButtonElement().click(); - - expect(dateTimePicker.vm.$emit).toHaveBeenCalledWith('onApply', { - end: '2019-10-19T00:00:00Z', - start: '2019-10-01T00:00:00Z', - }); - done(); - }); + fillInputAndBlur('#custom-time-from', '2019-10-01') + .then(() => fillInputAndBlur('#custom-time-to', '2019-10-19')) + .then(() => { + applyButtonElement().click(); + + expect(dateTimePicker.emitted().apply).toHaveLength(1); + expect(dateTimePicker.emitted().apply[0]).toEqual([ + { + end: '2019-10-19T00:00:00Z', + start: '2019-10-01T00:00:00Z', + }, + ]); + done(); + }) + .catch(done.fail); }); it('hides the popover with cancel button', done => { diff --git a/spec/frontend/monitoring/components/duplicate_dashboard_form_spec.js b/spec/frontend/monitoring/components/duplicate_dashboard_form_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..75a488b5c7b77d74784901f494e1589204685543 --- /dev/null +++ b/spec/frontend/monitoring/components/duplicate_dashboard_form_spec.js @@ -0,0 +1,153 @@ +import { mount } from '@vue/test-utils'; +import DuplicateDashboardForm from '~/monitoring/components/duplicate_dashboard_form.vue'; + +import { dashboardGitResponse } from '../mock_data'; + +describe('DuplicateDashboardForm', () => { + let wrapper; + + const defaultBranch = 'master'; + + const findByRef = ref => wrapper.find({ ref }); + const setValue = (ref, val) => { + findByRef(ref).setValue(val); + }; + const setChecked = value => { + const input = wrapper.find(`.form-check-input[value="${value}"]`); + input.element.checked = true; + input.trigger('click'); + input.trigger('change'); + }; + + beforeEach(() => { + // Use `mount` to render native input elements + wrapper = mount(DuplicateDashboardForm, { + propsData: { + dashboard: dashboardGitResponse[0], + defaultBranch, + }, + sync: false, + }); + }); + + it('renders correctly', () => { + expect(wrapper.exists()).toEqual(true); + }); + + it('renders form elements', () => { + expect(findByRef('fileName').exists()).toEqual(true); + expect(findByRef('branchName').exists()).toEqual(true); + expect(findByRef('branchOption').exists()).toEqual(true); + expect(findByRef('commitMessage').exists()).toEqual(true); + }); + + describe('validates the file name', () => { + const findInvalidFeedback = () => findByRef('fileNameFormGroup').find('.invalid-feedback'); + + it('when is empty', done => { + setValue('fileName', ''); + wrapper.vm.$nextTick(() => { + expect(findByRef('fileNameFormGroup').is('.is-valid')).toBe(true); + expect(findInvalidFeedback().exists()).toBe(false); + done(); + }); + }); + + it('when is valid', done => { + setValue('fileName', 'my_dashboard.yml'); + wrapper.vm.$nextTick(() => { + expect(findByRef('fileNameFormGroup').is('.is-valid')).toBe(true); + expect(findInvalidFeedback().exists()).toBe(false); + done(); + }); + }); + + it('when is not valid', done => { + setValue('fileName', 'my_dashboard.exe'); + wrapper.vm.$nextTick(() => { + expect(findByRef('fileNameFormGroup').is('.is-invalid')).toBe(true); + expect(findInvalidFeedback().text()).toBeTruthy(); + done(); + }); + }); + }); + + describe('emits `change` event', () => { + const lastChange = () => + wrapper.vm.$nextTick().then(() => { + wrapper.find('form').trigger('change'); + + // Resolves to the last emitted change + const changes = wrapper.emitted().change; + return changes[changes.length - 1][0]; + }); + + it('with the inital form values', () => { + expect(wrapper.emitted().change).toHaveLength(1); + expect(lastChange()).resolves.toEqual({ + branch: '', + commitMessage: expect.any(String), + dashboard: dashboardGitResponse[0].path, + fileName: 'common_metrics.yml', + }); + }); + + it('containing an inputted file name', () => { + setValue('fileName', 'my_dashboard.yml'); + + expect(lastChange()).resolves.toMatchObject({ + fileName: 'my_dashboard.yml', + }); + }); + + it('containing a default commit message when no message is set', () => { + setValue('commitMessage', ''); + + expect(lastChange()).resolves.toMatchObject({ + commitMessage: expect.stringContaining('Create custom dashboard'), + }); + }); + + it('containing an inputted commit message', () => { + setValue('commitMessage', 'My commit message'); + + expect(lastChange()).resolves.toMatchObject({ + commitMessage: expect.stringContaining('My commit message'), + }); + }); + + it('containing an inputted branch name', () => { + setValue('branchName', 'a-new-branch'); + + expect(lastChange()).resolves.toMatchObject({ + branch: 'a-new-branch', + }); + }); + + it('when a `default` branch option is set, branch input is invisible and ignored', done => { + setChecked(wrapper.vm.$options.radioVals.DEFAULT); + setValue('branchName', 'a-new-branch'); + + expect(lastChange()).resolves.toMatchObject({ + branch: defaultBranch, + }); + wrapper.vm.$nextTick(() => { + expect(findByRef('branchName').isVisible()).toBe(false); + done(); + }); + }); + + it('when `new` branch option is chosen, focuses on the branch name input', done => { + setChecked(wrapper.vm.$options.radioVals.NEW); + + wrapper.vm + .$nextTick() + .then(() => { + wrapper.find('form').trigger('change'); + expect(findByRef('branchName').is(':focus')).toBe(true); + }) + .then(done) + .catch(done.fail); + }); + }); +}); diff --git a/spec/javascripts/monitoring/components/graph_group_spec.js b/spec/frontend/monitoring/components/graph_group_spec.js similarity index 94% rename from spec/javascripts/monitoring/components/graph_group_spec.js rename to spec/frontend/monitoring/components/graph_group_spec.js index 43ca17c3cbcb59e4df750725e1c982f5832701a2..983785d0ecceec306b2ca8d9eebfab2f66bdcf9d 100644 --- a/spec/javascripts/monitoring/components/graph_group_spec.js +++ b/spec/frontend/monitoring/components/graph_group_spec.js @@ -1,9 +1,7 @@ -import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; import GraphGroup from '~/monitoring/components/graph_group.vue'; import Icon from '~/vue_shared/components/icon.vue'; -const localVue = createLocalVue(); - describe('Graph group component', () => { let wrapper; @@ -12,10 +10,8 @@ describe('Graph group component', () => { const findCaretIcon = () => wrapper.find(Icon); const createComponent = propsData => { - wrapper = shallowMount(localVue.extend(GraphGroup), { + wrapper = shallowMount(GraphGroup, { propsData, - sync: false, - localVue, }); }; diff --git a/spec/frontend/monitoring/init_utils.js b/spec/frontend/monitoring/init_utils.js new file mode 100644 index 0000000000000000000000000000000000000000..5f229cb6ee51f8894f885eaa9c58bd129077278b --- /dev/null +++ b/spec/frontend/monitoring/init_utils.js @@ -0,0 +1,57 @@ +import * as types from '~/monitoring/stores/mutation_types'; +import { + metricsGroupsAPIResponse, + mockedEmptyResult, + mockedQueryResultPayload, + mockedQueryResultPayloadCoresTotal, + mockApiEndpoint, + environmentData, +} from './mock_data'; + +export const propsData = { + hasMetrics: false, + documentationPath: '/path/to/docs', + settingsPath: '/path/to/settings', + clustersPath: '/path/to/clusters', + tagsPath: '/path/to/tags', + projectPath: '/path/to/project', + defaultBranch: 'master', + metricsEndpoint: mockApiEndpoint, + deploymentsEndpoint: null, + emptyGettingStartedSvgPath: '/path/to/getting-started.svg', + emptyLoadingSvgPath: '/path/to/loading.svg', + emptyNoDataSvgPath: '/path/to/no-data.svg', + emptyNoDataSmallSvgPath: '/path/to/no-data-small.svg', + emptyUnableToConnectSvgPath: '/path/to/unable-to-connect.svg', + environmentsEndpoint: '/root/hello-prometheus/environments/35', + currentEnvironmentName: 'production', + customMetricsAvailable: false, + customMetricsPath: '', + validateQueryPath: '', +}; + +export const setupComponentStore = wrapper => { + wrapper.vm.$store.commit( + `monitoringDashboard/${types.RECEIVE_METRICS_DATA_SUCCESS}`, + metricsGroupsAPIResponse, + ); + + // Load 3 panels to the dashboard, one with an empty result + wrapper.vm.$store.commit( + `monitoringDashboard/${types.RECEIVE_METRIC_RESULT_SUCCESS}`, + mockedEmptyResult, + ); + wrapper.vm.$store.commit( + `monitoringDashboard/${types.RECEIVE_METRIC_RESULT_SUCCESS}`, + mockedQueryResultPayload, + ); + wrapper.vm.$store.commit( + `monitoringDashboard/${types.RECEIVE_METRIC_RESULT_SUCCESS}`, + mockedQueryResultPayloadCoresTotal, + ); + + wrapper.vm.$store.commit( + `monitoringDashboard/${types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS}`, + environmentData, + ); +}; diff --git a/spec/frontend/monitoring/mock_data.js b/spec/frontend/monitoring/mock_data.js index 6ded22b4a3fd8f66dae1e379e588ac9f6294a055..8ed0e232775b58a21ab87d2d204775e54333e5fe 100644 --- a/spec/frontend/monitoring/mock_data.js +++ b/spec/frontend/monitoring/mock_data.js @@ -331,77 +331,80 @@ export const mockedQueryResultPayloadCoresTotal = { ], }; -export const metricsGroupsAPIResponse = [ - { - group: 'Response metrics (NGINX Ingress VTS)', - priority: 10, - panels: [ - { - metrics: [ - { - id: 'response_metrics_nginx_ingress_throughput_status_code', - label: 'Status Code', - metric_id: 1, - prometheus_endpoint_path: - '/root/autodevops-deploy/environments/32/prometheus/api/v1/query_range?query=sum%28rate%28nginx_upstream_responses_total%7Bupstream%3D~%22%25%7Bkube_namespace%7D-%25%7Bci_environment_slug%7D-.%2A%22%7D%5B2m%5D%29%29+by+%28status_code%29', - query_range: - 'sum(rate(nginx_upstream_responses_total{upstream=~"%{kube_namespace}-%{ci_environment_slug}-.*"}[2m])) by (status_code)', - unit: 'req / sec', - }, - ], - title: 'Throughput', - type: 'area-chart', - weight: 1, - y_label: 'Requests / Sec', - }, - ], - }, - { - group: 'System metrics (Kubernetes)', - priority: 5, - panels: [ - { - title: 'Memory Usage (Pod average)', - type: 'area-chart', - y_label: 'Memory Used per Pod', - weight: 2, - metrics: [ - { - id: 'system_metrics_kubernetes_container_memory_average', - query_range: - 'avg(sum(container_memory_usage_bytes{container_name!="POD",pod_name=~"^%{ci_environment_slug}-([^c].*|c([^a]|a([^n]|n([^a]|a([^r]|r[^y])))).*|)-(.*)",namespace="%{kube_namespace}"}) by (job)) without (job) / count(avg(container_memory_usage_bytes{container_name!="POD",pod_name=~"^%{ci_environment_slug}-([^c].*|c([^a]|a([^n]|n([^a]|a([^r]|r[^y])))).*|)-(.*)",namespace="%{kube_namespace}"}) without (job)) /1024/1024', - label: 'Pod average', - unit: 'MB', - metric_id: 17, - prometheus_endpoint_path: - '/root/autodevops-deploy/environments/32/prometheus/api/v1/query_range?query=avg%28sum%28container_memory_usage_bytes%7Bcontainer_name%21%3D%22POD%22%2Cpod_name%3D~%22%5E%25%7Bci_environment_slug%7D-%28%5B%5Ec%5D.%2A%7Cc%28%5B%5Ea%5D%7Ca%28%5B%5En%5D%7Cn%28%5B%5Ea%5D%7Ca%28%5B%5Er%5D%7Cr%5B%5Ey%5D%29%29%29%29.%2A%7C%29-%28.%2A%29%22%2Cnamespace%3D%22%25%7Bkube_namespace%7D%22%7D%29+by+%28job%29%29+without+%28job%29+%2F+count%28avg%28container_memory_usage_bytes%7Bcontainer_name%21%3D%22POD%22%2Cpod_name%3D~%22%5E%25%7Bci_environment_slug%7D-%28%5B%5Ec%5D.%2A%7Cc%28%5B%5Ea%5D%7Ca%28%5B%5En%5D%7Cn%28%5B%5Ea%5D%7Ca%28%5B%5Er%5D%7Cr%5B%5Ey%5D%29%29%29%29.%2A%7C%29-%28.%2A%29%22%2Cnamespace%3D%22%25%7Bkube_namespace%7D%22%7D%29+without+%28job%29%29+%2F1024%2F1024', - appearance: { - line: { - width: 2, +export const metricsGroupsAPIResponse = { + dashboard: 'Environment metrics', + panel_groups: [ + { + group: 'Response metrics (NGINX Ingress VTS)', + priority: 10, + panels: [ + { + metrics: [ + { + id: 'response_metrics_nginx_ingress_throughput_status_code', + label: 'Status Code', + metric_id: 1, + prometheus_endpoint_path: + '/root/autodevops-deploy/environments/32/prometheus/api/v1/query_range?query=sum%28rate%28nginx_upstream_responses_total%7Bupstream%3D~%22%25%7Bkube_namespace%7D-%25%7Bci_environment_slug%7D-.%2A%22%7D%5B2m%5D%29%29+by+%28status_code%29', + query_range: + 'sum(rate(nginx_upstream_responses_total{upstream=~"%{kube_namespace}-%{ci_environment_slug}-.*"}[2m])) by (status_code)', + unit: 'req / sec', + }, + ], + title: 'Throughput', + type: 'area-chart', + weight: 1, + y_label: 'Requests / Sec', + }, + ], + }, + { + group: 'System metrics (Kubernetes)', + priority: 5, + panels: [ + { + title: 'Memory Usage (Pod average)', + type: 'area-chart', + y_label: 'Memory Used per Pod', + weight: 2, + metrics: [ + { + id: 'system_metrics_kubernetes_container_memory_average', + query_range: + 'avg(sum(container_memory_usage_bytes{container_name!="POD",pod_name=~"^%{ci_environment_slug}-([^c].*|c([^a]|a([^n]|n([^a]|a([^r]|r[^y])))).*|)-(.*)",namespace="%{kube_namespace}"}) by (job)) without (job) / count(avg(container_memory_usage_bytes{container_name!="POD",pod_name=~"^%{ci_environment_slug}-([^c].*|c([^a]|a([^n]|n([^a]|a([^r]|r[^y])))).*|)-(.*)",namespace="%{kube_namespace}"}) without (job)) /1024/1024', + label: 'Pod average', + unit: 'MB', + metric_id: 17, + prometheus_endpoint_path: + '/root/autodevops-deploy/environments/32/prometheus/api/v1/query_range?query=avg%28sum%28container_memory_usage_bytes%7Bcontainer_name%21%3D%22POD%22%2Cpod_name%3D~%22%5E%25%7Bci_environment_slug%7D-%28%5B%5Ec%5D.%2A%7Cc%28%5B%5Ea%5D%7Ca%28%5B%5En%5D%7Cn%28%5B%5Ea%5D%7Ca%28%5B%5Er%5D%7Cr%5B%5Ey%5D%29%29%29%29.%2A%7C%29-%28.%2A%29%22%2Cnamespace%3D%22%25%7Bkube_namespace%7D%22%7D%29+by+%28job%29%29+without+%28job%29+%2F+count%28avg%28container_memory_usage_bytes%7Bcontainer_name%21%3D%22POD%22%2Cpod_name%3D~%22%5E%25%7Bci_environment_slug%7D-%28%5B%5Ec%5D.%2A%7Cc%28%5B%5Ea%5D%7Ca%28%5B%5En%5D%7Cn%28%5B%5Ea%5D%7Ca%28%5B%5Er%5D%7Cr%5B%5Ey%5D%29%29%29%29.%2A%7C%29-%28.%2A%29%22%2Cnamespace%3D%22%25%7Bkube_namespace%7D%22%7D%29+without+%28job%29%29+%2F1024%2F1024', + appearance: { + line: { + width: 2, + }, }, }, - }, - ], - }, - { - title: 'Core Usage (Total)', - type: 'area-chart', - y_label: 'Total Cores', - weight: 3, - metrics: [ - { - id: 'system_metrics_kubernetes_container_cores_total', - query_range: - 'avg(sum(rate(container_cpu_usage_seconds_total{container_name!="POD",pod_name=~"^%{ci_environment_slug}-(.*)",namespace="%{kube_namespace}"}[15m])) by (job)) without (job)', - label: 'Total', - unit: 'cores', - metric_id: 13, - }, - ], - }, - ], - }, -]; + ], + }, + { + title: 'Core Usage (Total)', + type: 'area-chart', + y_label: 'Total Cores', + weight: 3, + metrics: [ + { + id: 'system_metrics_kubernetes_container_cores_total', + query_range: + 'avg(sum(rate(container_cpu_usage_seconds_total{container_name!="POD",pod_name=~"^%{ci_environment_slug}-(.*)",namespace="%{kube_namespace}"}[15m])) by (job)) without (job)', + label: 'Total', + unit: 'cores', + metric_id: 13, + }, + ], + }, + ], + }, + ], +}; export const environmentData = [ { @@ -519,6 +522,7 @@ export const dashboardGitResponse = [ default: true, display_name: 'Default', can_edit: false, + system_dashboard: true, project_blob_path: null, path: 'config/prometheus/common_metrics.yml', }, @@ -526,6 +530,7 @@ export const dashboardGitResponse = [ default: false, display_name: 'Custom Dashboard 1', can_edit: true, + system_dashboard: false, project_blob_path: `${mockProjectDir}/blob/master/dashboards/.gitlab/dashboards/dashboard_1.yml`, path: '.gitlab/dashboards/dashboard_1.yml', }, @@ -533,6 +538,7 @@ export const dashboardGitResponse = [ default: false, display_name: 'Custom Dashboard 2', can_edit: true, + system_dashboard: false, project_blob_path: `${mockProjectDir}/blob/master/dashboards/.gitlab/dashboards/dashboard_2.yml`, path: '.gitlab/dashboards/dashboard_2.yml', }, diff --git a/spec/frontend/monitoring/panel_type_spec.js b/spec/frontend/monitoring/panel_type_spec.js index c869d77673e0c86aacfa8ea77408a53e7bf446e7..e51b69ef14de4050cf45edc68177ea3ca1ba372c 100644 --- a/spec/frontend/monitoring/panel_type_spec.js +++ b/spec/frontend/monitoring/panel_type_spec.js @@ -26,8 +26,6 @@ describe('Panel Type component', () => { ...props, }, store, - sync: false, - attachToDocument: true, }); beforeEach(() => { @@ -152,8 +150,6 @@ describe('Panel Type component', () => { graphData: graphDataPrometheusQueryRange, }, store, - sync: false, - attachToDocument: true, }); panelType.vm.$nextTick(done); }); diff --git a/spec/javascripts/monitoring/shared/prometheus_header_spec.js b/spec/frontend/monitoring/shared/prometheus_header_spec.js similarity index 86% rename from spec/javascripts/monitoring/shared/prometheus_header_spec.js rename to spec/frontend/monitoring/shared/prometheus_header_spec.js index 9f916a4dfbb43552c711d8940e920d93afdc3d5a..b216bfb72d8321160aa8c307ffcad2f8d8ab662b 100644 --- a/spec/javascripts/monitoring/shared/prometheus_header_spec.js +++ b/spec/frontend/monitoring/shared/prometheus_header_spec.js @@ -18,7 +18,7 @@ describe('Prometheus Header component', () => { describe('Prometheus header component', () => { it('should show a title', () => { - const title = prometheusHeader.vm.$el.querySelector('.js-graph-title').textContent; + const title = prometheusHeader.find({ ref: 'title' }).text(); expect(title).toBe('graph header'); }); diff --git a/spec/frontend/monitoring/store/actions_spec.js b/spec/frontend/monitoring/store/actions_spec.js index f38bd4384e2576f5d122ea67d5062b980943e7d5..975bdd3a27a68fd288b188560ddf314ed8dba10e 100644 --- a/spec/frontend/monitoring/store/actions_spec.js +++ b/spec/frontend/monitoring/store/actions_spec.js @@ -18,6 +18,7 @@ import { fetchPrometheusMetric, setEndpoints, setGettingStartedEmptyState, + duplicateSystemDashboard, } from '~/monitoring/stores/actions'; import storeState from '~/monitoring/stores/state'; import { @@ -298,7 +299,7 @@ describe('Monitoring store actions', () => { ); expect(commit).toHaveBeenCalledWith( types.RECEIVE_METRICS_DATA_SUCCESS, - metricsDashboardResponse.dashboard.panel_groups, + metricsDashboardResponse.dashboard, ); expect(dispatch).toHaveBeenCalledWith('fetchPrometheusMetrics', params); }); @@ -441,7 +442,7 @@ describe('Monitoring store actions', () => { beforeEach(() => { state = storeState(); [metric] = metricsDashboardResponse.dashboard.panel_groups[0].panels[0].metrics; - [data] = metricsGroupsAPIResponse[0].panels[0].metrics; + [data] = metricsGroupsAPIResponse.panel_groups[0].panels[0].metrics; }); it('commits result', done => { @@ -544,4 +545,85 @@ describe('Monitoring store actions', () => { }); }); }); + + describe('duplicateSystemDashboard', () => { + let state; + + beforeEach(() => { + state = storeState(); + state.dashboardsEndpoint = '/dashboards.json'; + }); + + it('Succesful POST request resolves', done => { + mock.onPost(state.dashboardsEndpoint).reply(statusCodes.CREATED, { + dashboard: dashboardGitResponse[1], + }); + + testAction(duplicateSystemDashboard, {}, state, [], []) + .then(() => { + expect(mock.history.post).toHaveLength(1); + done(); + }) + .catch(done.fail); + }); + + it('Succesful POST request resolves to a dashboard', done => { + const mockCreatedDashboard = dashboardGitResponse[1]; + + const params = { + dashboard: 'my-dashboard', + fileName: 'file-name.yml', + branch: 'my-new-branch', + commitMessage: 'A new commit message', + }; + + const expectedPayload = JSON.stringify({ + dashboard: 'my-dashboard', + file_name: 'file-name.yml', + branch: 'my-new-branch', + commit_message: 'A new commit message', + }); + + mock.onPost(state.dashboardsEndpoint).reply(statusCodes.CREATED, { + dashboard: mockCreatedDashboard, + }); + + testAction(duplicateSystemDashboard, params, state, [], []) + .then(result => { + expect(mock.history.post).toHaveLength(1); + expect(mock.history.post[0].data).toEqual(expectedPayload); + expect(result).toEqual(mockCreatedDashboard); + + done(); + }) + .catch(done.fail); + }); + + it('Failed POST request throws an error', done => { + mock.onPost(state.dashboardsEndpoint).reply(statusCodes.BAD_REQUEST); + + testAction(duplicateSystemDashboard, {}, state, [], []).catch(err => { + expect(mock.history.post).toHaveLength(1); + expect(err).toEqual(expect.any(String)); + + done(); + }); + }); + + it('Failed POST request throws an error with a description', done => { + const backendErrorMsg = 'This file already exists!'; + + mock.onPost(state.dashboardsEndpoint).reply(statusCodes.BAD_REQUEST, { + error: backendErrorMsg, + }); + + testAction(duplicateSystemDashboard, {}, state, [], []).catch(err => { + expect(mock.history.post).toHaveLength(1); + expect(err).toEqual(expect.any(String)); + expect(err).toEqual(expect.stringContaining(backendErrorMsg)); + + done(); + }); + }); + }); }); diff --git a/spec/frontend/monitoring/store/mutations_spec.js b/spec/frontend/monitoring/store/mutations_spec.js index 60107a03674bc33bf6caee3f0e981885ebcbe868..cb53ab60bdbffd1c4068db998949599c888a46f1 100644 --- a/spec/frontend/monitoring/store/mutations_spec.js +++ b/spec/frontend/monitoring/store/mutations_spec.js @@ -29,8 +29,8 @@ describe('Monitoring mutations', () => { mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, payload); const groups = getGroups(); - expect(groups[0].key).toBe('response-metrics-nginx-ingress-vts--0'); - expect(groups[1].key).toBe('system-metrics-kubernetes--1'); + expect(groups[0].key).toBe('response-metrics-nginx-ingress-vts-0'); + expect(groups[1].key).toBe('system-metrics-kubernetes-1'); }); it('normalizes values', () => { mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, payload); @@ -100,12 +100,12 @@ describe('Monitoring mutations', () => { values: [[0, 1], [1, 1], [1, 3]], }, ]; - const dashboardGroups = metricsDashboardResponse.dashboard.panel_groups; + const { dashboard } = metricsDashboardResponse; const getMetric = () => stateCopy.dashboard.panel_groups[0].panels[0].metrics[0]; describe('REQUEST_METRIC_RESULT', () => { beforeEach(() => { - mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, dashboardGroups); + mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, dashboard); }); it('stores a loading state on a metric', () => { expect(stateCopy.showEmptyState).toBe(true); @@ -128,7 +128,7 @@ describe('Monitoring mutations', () => { describe('RECEIVE_METRIC_RESULT_SUCCESS', () => { beforeEach(() => { - mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, dashboardGroups); + mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, dashboard); }); it('clears empty state', () => { expect(stateCopy.showEmptyState).toBe(true); @@ -161,7 +161,7 @@ describe('Monitoring mutations', () => { describe('RECEIVE_METRIC_RESULT_FAILURE', () => { beforeEach(() => { - mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, dashboardGroups); + mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, dashboard); }); it('maintains the loading state when a metric fails', () => { expect(stateCopy.showEmptyState).toBe(true); diff --git a/spec/frontend/mr_popover/__snapshots__/mr_popover_spec.js.snap b/spec/frontend/mr_popover/__snapshots__/mr_popover_spec.js.snap index a2a7d0ee91e1f58ca2bdf70a10a37dcc5d50521d..3229492506a28d961fb81eb8736122bd433cd5f2 100644 --- a/spec/frontend/mr_popover/__snapshots__/mr_popover_spec.js.snap +++ b/spec/frontend/mr_popover/__snapshots__/mr_popover_spec.js.snap @@ -1,7 +1,7 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`MR Popover loaded state matches the snapshot 1`] = ` -<glpopover-stub +<gl-popover-stub boundary="viewport" cssclasses="" placement="top" @@ -35,7 +35,7 @@ exports[`MR Popover loaded state matches the snapshot 1`] = ` </span> </div> - <ciicon-stub + <ci-icon-stub cssclasses="" size="16" status="[object Object]" @@ -56,11 +56,11 @@ exports[`MR Popover loaded state matches the snapshot 1`] = ` </div> </div> -</glpopover-stub> +</gl-popover-stub> `; exports[`MR Popover shows skeleton-loader while apollo is loading 1`] = ` -<glpopover-stub +<gl-popover-stub boundary="viewport" cssclasses="" placement="top" @@ -71,7 +71,7 @@ exports[`MR Popover shows skeleton-loader while apollo is loading 1`] = ` class="mr-popover" > <div> - <glskeletonloading-stub + <gl-skeleton-loading-stub class="animation-container-small mt-1" lines="1" /> @@ -91,5 +91,5 @@ exports[`MR Popover shows skeleton-loader while apollo is loading 1`] = ` </div> </div> -</glpopover-stub> +</gl-popover-stub> `; diff --git a/spec/frontend/mr_popover/mr_popover_spec.js b/spec/frontend/mr_popover/mr_popover_spec.js index e72b729f0561d2cbcbf79426a7bc48d88da834ee..0c0d4c73d9132e8417578c09a111bb95434f461c 100644 --- a/spec/frontend/mr_popover/mr_popover_spec.js +++ b/spec/frontend/mr_popover/mr_popover_spec.js @@ -1,5 +1,6 @@ import { shallowMount } from '@vue/test-utils'; import MRPopover from '~/mr_popover/components/mr_popover'; +import CiIcon from '~/vue_shared/components/ci_icon.vue'; describe('MR Popover', () => { let wrapper; @@ -23,7 +24,9 @@ describe('MR Popover', () => { it('shows skeleton-loader while apollo is loading', () => { wrapper.vm.$apollo.loading = true; - expect(wrapper.element).toMatchSnapshot(); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.element).toMatchSnapshot(); + }); }); describe('loaded state', () => { @@ -41,7 +44,9 @@ describe('MR Popover', () => { }, }); - expect(wrapper.element).toMatchSnapshot(); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.element).toMatchSnapshot(); + }); }); it('does not show CI Icon if there is no pipeline data', () => { @@ -55,7 +60,9 @@ describe('MR Popover', () => { }, }); - expect(wrapper.contains('ciicon-stub')).toBe(false); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.contains(CiIcon)).toBe(false); + }); }); }); }); diff --git a/spec/javascripts/namespace_select_spec.js b/spec/frontend/namespace_select_spec.js similarity index 82% rename from spec/javascripts/namespace_select_spec.js rename to spec/frontend/namespace_select_spec.js index 07b82ce721e2bf9d017ac1da4928af60f003aa5b..399fa95076927062b4998fed0386455924271edc 100644 --- a/spec/javascripts/namespace_select_spec.js +++ b/spec/frontend/namespace_select_spec.js @@ -3,7 +3,7 @@ import NamespaceSelect from '~/namespace_select'; describe('NamespaceSelect', () => { beforeEach(() => { - spyOn($.fn, 'glDropdown'); + jest.spyOn($.fn, 'glDropdown').mockImplementation(() => {}); }); it('initializes glDropdown', () => { @@ -22,12 +22,12 @@ describe('NamespaceSelect', () => { const dropdown = document.createElement('div'); // eslint-disable-next-line no-new new NamespaceSelect({ dropdown }); - [glDropdownOptions] = $.fn.glDropdown.calls.argsFor(0); + [[glDropdownOptions]] = $.fn.glDropdown.mock.calls; }); it('prevents click events', () => { const dummyEvent = new Event('dummy'); - spyOn(dummyEvent, 'preventDefault'); + jest.spyOn(dummyEvent, 'preventDefault').mockImplementation(() => {}); glDropdownOptions.clicked({ e: dummyEvent }); @@ -43,12 +43,12 @@ describe('NamespaceSelect', () => { dropdown.dataset.isFilter = 'true'; // eslint-disable-next-line no-new new NamespaceSelect({ dropdown }); - [glDropdownOptions] = $.fn.glDropdown.calls.argsFor(0); + [[glDropdownOptions]] = $.fn.glDropdown.mock.calls; }); it('does not prevent click events', () => { const dummyEvent = new Event('dummy'); - spyOn(dummyEvent, 'preventDefault'); + jest.spyOn(dummyEvent, 'preventDefault').mockImplementation(() => {}); glDropdownOptions.clicked({ e: dummyEvent }); diff --git a/spec/javascripts/new_branch_spec.js b/spec/frontend/new_branch_spec.js similarity index 73% rename from spec/javascripts/new_branch_spec.js rename to spec/frontend/new_branch_spec.js index 4e3140ce4f1ef2fde3eed880d8e86725448854ce..cff7ec1a9ee8b4520b7377fffd32a02dfd1165a7 100644 --- a/spec/javascripts/new_branch_spec.js +++ b/spec/frontend/new_branch_spec.js @@ -1,8 +1,14 @@ import $ from 'jquery'; import NewBranchForm from '~/new_branch_form'; -describe('Branch', function() { - describe('create a new branch', function() { +describe('Branch', () => { + let testContext; + + beforeEach(() => { + testContext = {}; + }); + + describe('create a new branch', () => { preloadFixtures('branches/new_branch.html'); function fillNameWith(value) { @@ -15,30 +21,28 @@ describe('Branch', function() { expect($('.js-branch-name-error span').text()).toEqual(error); } - beforeEach(function() { + beforeEach(() => { loadFixtures('branches/new_branch.html'); - $('form').on('submit', function(e) { - return e.preventDefault(); - }); - this.form = new NewBranchForm($('.js-create-branch-form'), []); + $('form').on('submit', e => e.preventDefault()); + testContext.form = new NewBranchForm($('.js-create-branch-form'), []); }); - it("can't start with a dot", function() { + it("can't start with a dot", () => { fillNameWith('.foo'); expectToHaveError("can't start with '.'"); }); - it("can't start with a slash", function() { + it("can't start with a slash", () => { fillNameWith('/foo'); expectToHaveError("can't start with '/'"); }); - it("can't have two consecutive dots", function() { + it("can't have two consecutive dots", () => { fillNameWith('foo..bar'); expectToHaveError("can't contain '..'"); }); - it("can't have spaces anywhere", function() { + it("can't have spaces anywhere", () => { fillNameWith(' foo'); expectToHaveError("can't contain spaces"); fillNameWith('foo bar'); @@ -47,7 +51,7 @@ describe('Branch', function() { expectToHaveError("can't contain spaces"); }); - it("can't have ~ anywhere", function() { + it("can't have ~ anywhere", () => { fillNameWith('~foo'); expectToHaveError("can't contain '~'"); fillNameWith('foo~bar'); @@ -56,7 +60,7 @@ describe('Branch', function() { expectToHaveError("can't contain '~'"); }); - it("can't have tilde anwhere", function() { + it("can't have tilde anwhere", () => { fillNameWith('~foo'); expectToHaveError("can't contain '~'"); fillNameWith('foo~bar'); @@ -65,7 +69,7 @@ describe('Branch', function() { expectToHaveError("can't contain '~'"); }); - it("can't have caret anywhere", function() { + it("can't have caret anywhere", () => { fillNameWith('^foo'); expectToHaveError("can't contain '^'"); fillNameWith('foo^bar'); @@ -74,7 +78,7 @@ describe('Branch', function() { expectToHaveError("can't contain '^'"); }); - it("can't have : anywhere", function() { + it("can't have : anywhere", () => { fillNameWith(':foo'); expectToHaveError("can't contain ':'"); fillNameWith('foo:bar'); @@ -83,7 +87,7 @@ describe('Branch', function() { expectToHaveError("can't contain ':'"); }); - it("can't have question mark anywhere", function() { + it("can't have question mark anywhere", () => { fillNameWith('?foo'); expectToHaveError("can't contain '?'"); fillNameWith('foo?bar'); @@ -92,7 +96,7 @@ describe('Branch', function() { expectToHaveError("can't contain '?'"); }); - it("can't have asterisk anywhere", function() { + it("can't have asterisk anywhere", () => { fillNameWith('*foo'); expectToHaveError("can't contain '*'"); fillNameWith('foo*bar'); @@ -101,7 +105,7 @@ describe('Branch', function() { expectToHaveError("can't contain '*'"); }); - it("can't have open bracket anywhere", function() { + it("can't have open bracket anywhere", () => { fillNameWith('[foo'); expectToHaveError("can't contain '['"); fillNameWith('foo[bar'); @@ -110,7 +114,7 @@ describe('Branch', function() { expectToHaveError("can't contain '['"); }); - it("can't have a backslash anywhere", function() { + it("can't have a backslash anywhere", () => { fillNameWith('\\foo'); expectToHaveError("can't contain '\\'"); fillNameWith('foo\\bar'); @@ -119,7 +123,7 @@ describe('Branch', function() { expectToHaveError("can't contain '\\'"); }); - it("can't contain a sequence @{ anywhere", function() { + it("can't contain a sequence @{ anywhere", () => { fillNameWith('@{foo'); expectToHaveError("can't contain '@{'"); fillNameWith('foo@{bar'); @@ -128,42 +132,42 @@ describe('Branch', function() { expectToHaveError("can't contain '@{'"); }); - it("can't have consecutive slashes", function() { + it("can't have consecutive slashes", () => { fillNameWith('foo//bar'); expectToHaveError("can't contain consecutive slashes"); }); - it("can't end with a slash", function() { + it("can't end with a slash", () => { fillNameWith('foo/'); expectToHaveError("can't end in '/'"); }); - it("can't end with a dot", function() { + it("can't end with a dot", () => { fillNameWith('foo.'); expectToHaveError("can't end in '.'"); }); - it("can't end with .lock", function() { + it("can't end with .lock", () => { fillNameWith('foo.lock'); expectToHaveError("can't end in '.lock'"); }); - it("can't be the single character @", function() { + it("can't be the single character @", () => { fillNameWith('@'); expectToHaveError("can't be '@'"); }); - it('concatenates all error messages', function() { + it('concatenates all error messages', () => { fillNameWith('/foo bar?~.'); expectToHaveError("can't start with '/', can't contain spaces, '?', '~', can't end in '.'"); }); - it("doesn't duplicate error messages", function() { + it("doesn't duplicate error messages", () => { fillNameWith('?foo?bar?zoo?'); expectToHaveError("can't contain '?'"); }); - it('removes the error message when is a valid name', function() { + it('removes the error message when is a valid name', () => { fillNameWith('foo?bar'); expect($('.js-branch-name-error span').length).toEqual(1); @@ -172,25 +176,25 @@ describe('Branch', function() { expect($('.js-branch-name-error span').length).toEqual(0); }); - it('can have dashes anywhere', function() { + it('can have dashes anywhere', () => { fillNameWith('-foo-bar-zoo-'); expect($('.js-branch-name-error span').length).toEqual(0); }); - it('can have underscores anywhere', function() { + it('can have underscores anywhere', () => { fillNameWith('_foo_bar_zoo_'); expect($('.js-branch-name-error span').length).toEqual(0); }); - it('can have numbers anywhere', function() { + it('can have numbers anywhere', () => { fillNameWith('1foo2bar3zoo4'); expect($('.js-branch-name-error span').length).toEqual(0); }); - it('can be only letters', function() { + it('can be only letters', () => { fillNameWith('foo'); expect($('.js-branch-name-error span').length).toEqual(0); diff --git a/spec/frontend/notes/components/__snapshots__/discussion_jump_to_next_button_spec.js.snap b/spec/frontend/notes/components/__snapshots__/discussion_jump_to_next_button_spec.js.snap index b29d093130a97d7ced35ba2b676f4e52eeb7380f..1e466f266ed84ff2775b07cfdadf74465e823df4 100644 --- a/spec/frontend/notes/components/__snapshots__/discussion_jump_to_next_button_spec.js.snap +++ b/spec/frontend/notes/components/__snapshots__/discussion_jump_to_next_button_spec.js.snap @@ -7,8 +7,7 @@ exports[`JumpToNextDiscussionButton matches the snapshot 1`] = ` > <button class="btn btn-default discussion-next-btn" - data-original-title="Jump to next unresolved discussion" - title="" + title="Jump to next unresolved discussion" > <icon-stub name="comment-next" diff --git a/spec/frontend/notes/components/comment_form_spec.js b/spec/frontend/notes/components/comment_form_spec.js index 7652f48474d58188da993f09688b35fe43b517fe..ceba31b1a70ad9aa0e7a1246424cec1526c09cb9 100644 --- a/spec/frontend/notes/components/comment_form_spec.js +++ b/spec/frontend/notes/components/comment_form_spec.js @@ -37,8 +37,6 @@ describe('issue_comment_form component', () => { noteableType, }, store, - sync: false, - attachToDocument: true, }); }; diff --git a/spec/frontend/notes/components/diff_discussion_header_spec.js b/spec/frontend/notes/components/diff_discussion_header_spec.js index f90147f910533860553dc2699b21a9c5b37eefc4..4c76f9c50fb9461838dccaae9959869cbee94118 100644 --- a/spec/frontend/notes/components/diff_discussion_header_spec.js +++ b/spec/frontend/notes/components/diff_discussion_header_spec.js @@ -1,4 +1,4 @@ -import { mount, createLocalVue } from '@vue/test-utils'; +import { mount } from '@vue/test-utils'; import createStore from '~/notes/stores'; import diffDiscussionHeader from '~/notes/components/diff_discussion_header.vue'; @@ -18,12 +18,9 @@ describe('diff_discussion_header component', () => { window.mrTabs = {}; store = createStore(); - const localVue = createLocalVue(); wrapper = mount(diffDiscussionHeader, { store, propsData: { discussion: discussionMock }, - localVue, - sync: false, }); }); @@ -38,7 +35,9 @@ describe('diff_discussion_header component', () => { wrapper.setProps({ discussion }); - expect(wrapper.find('.user-avatar-link').exists()).toBe(true); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.find('.user-avatar-link').exists()).toBe(true); + }); }); describe('action text', () => { diff --git a/spec/frontend/notes/components/discussion_actions_spec.js b/spec/frontend/notes/components/discussion_actions_spec.js index 6198f8b3c1d31d0ac77ec30e6d2cb51527dca8bd..2d95a86d8a6aca723ef12d024601169fe0bf6785 100644 --- a/spec/frontend/notes/components/discussion_actions_spec.js +++ b/spec/frontend/notes/components/discussion_actions_spec.js @@ -1,4 +1,4 @@ -import { shallowMount, mount, createLocalVue } from '@vue/test-utils'; +import { shallowMount, mount } from '@vue/test-utils'; import { discussionMock } from '../../notes/mock_data'; import DiscussionActions from '~/notes/components/discussion_actions.vue'; import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue'; @@ -22,12 +22,10 @@ const createUnallowedNote = () => describe('DiscussionActions', () => { let wrapper; const createComponentFactory = (shallow = true) => props => { - const localVue = createLocalVue(); const store = createStore(); const mountFn = shallow ? shallowMount : mount; wrapper = mountFn(DiscussionActions, { - localVue, store, propsData: { discussion: discussionMock, @@ -37,8 +35,6 @@ describe('DiscussionActions', () => { shouldShowJumpToNextDiscussion: true, ...props, }, - sync: false, - attachToDocument: true, }); }; diff --git a/spec/javascripts/notes/components/discussion_filter_note_spec.js b/spec/frontend/notes/components/discussion_filter_note_spec.js similarity index 93% rename from spec/javascripts/notes/components/discussion_filter_note_spec.js rename to spec/frontend/notes/components/discussion_filter_note_spec.js index 52d2e7ce947c8b8da09a85a047427a3af6be1b7e..6b5f42a84e8cd60b2679500d44a851185a73ed8b 100644 --- a/spec/javascripts/notes/components/discussion_filter_note_spec.js +++ b/spec/frontend/notes/components/discussion_filter_note_spec.js @@ -34,7 +34,7 @@ describe('DiscussionFilterNote component', () => { describe('methods', () => { describe('selectFilter', () => { it('emits `dropdownSelect` event on `eventHub` with provided param', () => { - spyOn(eventHub, '$emit'); + jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); vm.selectFilter(1); @@ -74,7 +74,7 @@ describe('DiscussionFilterNote component', () => { it('clicking `Show all activity` button calls `selectFilter("all")` method', () => { const showAllBtn = vm.$el.querySelector('.discussion-filter-actions button:first-child'); - spyOn(vm, 'selectFilter'); + jest.spyOn(vm, 'selectFilter').mockImplementation(() => {}); showAllBtn.dispatchEvent(new Event('click')); @@ -83,7 +83,7 @@ describe('DiscussionFilterNote component', () => { it('clicking `Show comments only` button calls `selectFilter("comments")` method', () => { const showAllBtn = vm.$el.querySelector('.discussion-filter-actions button:last-child'); - spyOn(vm, 'selectFilter'); + jest.spyOn(vm, 'selectFilter').mockImplementation(() => {}); showAllBtn.dispatchEvent(new Event('click')); diff --git a/spec/frontend/notes/components/discussion_jump_to_next_button_spec.js b/spec/frontend/notes/components/discussion_jump_to_next_button_spec.js index 3986340b6fd2e13dc6593705157cadeebfa88dce..58cdf3cb57ec1f502dd5a1078f99b4cfa7717805 100644 --- a/spec/frontend/notes/components/discussion_jump_to_next_button_spec.js +++ b/spec/frontend/notes/components/discussion_jump_to_next_button_spec.js @@ -5,10 +5,7 @@ describe('JumpToNextDiscussionButton', () => { let wrapper; beforeEach(() => { - wrapper = shallowMount(JumpToNextDiscussionButton, { - sync: false, - attachToDocument: true, - }); + wrapper = shallowMount(JumpToNextDiscussionButton); }); afterEach(() => { @@ -24,7 +21,9 @@ describe('JumpToNextDiscussionButton', () => { button.trigger('click'); - expect(wrapper.emitted().onClick).toBeTruthy(); - expect(wrapper.emitted().onClick.length).toBe(1); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.emitted().onClick).toBeTruthy(); + expect(wrapper.emitted().onClick.length).toBe(1); + }); }); }); diff --git a/spec/frontend/notes/components/discussion_notes_replies_wrapper_spec.js b/spec/frontend/notes/components/discussion_notes_replies_wrapper_spec.js index 279ca017b44f6a1b1e30a29fa634fed4c5a0c8f6..8d5ea108b50d7154b28d66c096693fd71f515172 100644 --- a/spec/frontend/notes/components/discussion_notes_replies_wrapper_spec.js +++ b/spec/frontend/notes/components/discussion_notes_replies_wrapper_spec.js @@ -16,7 +16,6 @@ describe('DiscussionNotesRepliesWrapper', () => { const createComponent = (props = {}) => { wrapper = mount(TestComponent, { propsData: props, - sync: false, }); }; @@ -30,7 +29,7 @@ describe('DiscussionNotesRepliesWrapper', () => { }); it('renders children directly', () => { - expect(wrapper.html()).toEqual(`<ul>${TEST_CHILDREN}</ul>`); + expect(wrapper.element.outerHTML).toEqual(`<ul>${TEST_CHILDREN}</ul>`); }); }); @@ -45,7 +44,7 @@ describe('DiscussionNotesRepliesWrapper', () => { const notes = wrapper.find('li.discussion-collapsible ul.notes'); expect(notes.exists()).toBe(true); - expect(notes.html()).toEqual(`<ul class="notes">${TEST_CHILDREN}</ul>`); + expect(notes.element.outerHTML).toEqual(`<ul class="notes">${TEST_CHILDREN}</ul>`); }); }); }); diff --git a/spec/frontend/notes/components/discussion_notes_spec.js b/spec/frontend/notes/components/discussion_notes_spec.js index 5ab26d742ca958f123c0c2f9afb487258bf8b19a..8177375203758f0027cba45888ba8a8045482516 100644 --- a/spec/frontend/notes/components/discussion_notes_spec.js +++ b/spec/frontend/notes/components/discussion_notes_spec.js @@ -1,4 +1,4 @@ -import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; import '~/behaviors/markdown/render_gfm'; import { SYSTEM_NOTE } from '~/notes/constants'; import DiscussionNotes from '~/notes/components/discussion_notes.vue'; @@ -6,12 +6,9 @@ import NoteableNote from '~/notes/components/noteable_note.vue'; import PlaceholderNote from '~/vue_shared/components/notes/placeholder_note.vue'; import PlaceholderSystemNote from '~/vue_shared/components/notes/placeholder_system_note.vue'; import SystemNote from '~/vue_shared/components/notes/system_note.vue'; -import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; import createStore from '~/notes/stores'; import { noteableDataMock, discussionMock, notesDataMock } from '../../notes/mock_data'; -const localVue = createLocalVue(); - describe('DiscussionNotes', () => { let wrapper; @@ -21,7 +18,6 @@ describe('DiscussionNotes', () => { store.dispatch('setNotesData', notesDataMock); wrapper = shallowMount(DiscussionNotes, { - localVue, store, propsData: { discussion: discussionMock, @@ -35,8 +31,6 @@ describe('DiscussionNotes', () => { slots: { 'avatar-badge': '<span class="avatar-badge-slot-content" />', }, - sync: false, - attachToDocument: true, }); }; @@ -48,13 +42,13 @@ describe('DiscussionNotes', () => { it('renders an element for each note in the discussion', () => { createComponent(); const notesCount = discussionMock.notes.length; - const els = wrapper.findAll(TimelineEntryItem); + const els = wrapper.findAll(NoteableNote); expect(els.length).toBe(notesCount); }); it('renders one element if replies groupping is enabled', () => { createComponent({ shouldGroupReplies: true }); - const els = wrapper.findAll(TimelineEntryItem); + const els = wrapper.findAll(NoteableNote); expect(els.length).toBe(1); }); @@ -85,7 +79,7 @@ describe('DiscussionNotes', () => { ]; discussion.notes = notesData; createComponent({ discussion, shouldRenderDiffs: true }); - const notes = wrapper.findAll('.notes > li'); + const notes = wrapper.findAll('.notes > *'); expect(notes.at(0).is(PlaceholderSystemNote)).toBe(true); expect(notes.at(1).is(PlaceholderNote)).toBe(true); @@ -111,7 +105,14 @@ describe('DiscussionNotes', () => { describe('events', () => { describe('with groupped notes and replies expanded', () => { - const findNoteAtIndex = index => wrapper.find(`.note:nth-of-type(${index + 1}`); + const findNoteAtIndex = index => { + const noteComponents = [NoteableNote, SystemNote, PlaceholderNote, PlaceholderSystemNote]; + const allowedNames = noteComponents.map(c => c.name); + return wrapper + .findAll('.notes *') + .filter(w => allowedNames.includes(w.name())) + .at(index); + }; beforeEach(() => { createComponent({ shouldGroupReplies: true, isExpanded: true }); @@ -119,17 +120,26 @@ describe('DiscussionNotes', () => { it('emits deleteNote when first note emits handleDeleteNote', () => { findNoteAtIndex(0).vm.$emit('handleDeleteNote'); - expect(wrapper.emitted().deleteNote).toBeTruthy(); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.emitted().deleteNote).toBeTruthy(); + }); }); it('emits startReplying when first note emits startReplying', () => { findNoteAtIndex(0).vm.$emit('startReplying'); - expect(wrapper.emitted().startReplying).toBeTruthy(); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.emitted().startReplying).toBeTruthy(); + }); }); it('emits deleteNote when second note emits handleDeleteNote', () => { findNoteAtIndex(1).vm.$emit('handleDeleteNote'); - expect(wrapper.emitted().deleteNote).toBeTruthy(); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.emitted().deleteNote).toBeTruthy(); + }); }); }); @@ -137,12 +147,15 @@ describe('DiscussionNotes', () => { let note; beforeEach(() => { createComponent(); - note = wrapper.find('.note'); + note = wrapper.find('.notes > *'); }); it('emits deleteNote when first note emits handleDeleteNote', () => { note.vm.$emit('handleDeleteNote'); - expect(wrapper.emitted().deleteNote).toBeTruthy(); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.emitted().deleteNote).toBeTruthy(); + }); }); }); }); diff --git a/spec/frontend/notes/components/discussion_reply_placeholder_spec.js b/spec/frontend/notes/components/discussion_reply_placeholder_spec.js index 3152b6ff241fc7bfe990337e4a8dcf7461337e95..a881e44a007bac353346d3f6a52f732a3c39c0e8 100644 --- a/spec/frontend/notes/components/discussion_reply_placeholder_spec.js +++ b/spec/frontend/notes/components/discussion_reply_placeholder_spec.js @@ -1,7 +1,6 @@ -import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue'; -const localVue = createLocalVue(); const buttonText = 'Test Button Text'; describe('ReplyPlaceholder', () => { @@ -11,7 +10,6 @@ describe('ReplyPlaceholder', () => { beforeEach(() => { wrapper = shallowMount(ReplyPlaceholder, { - localVue, propsData: { buttonText, }, @@ -25,8 +23,10 @@ describe('ReplyPlaceholder', () => { it('emits onClick even on button click', () => { findButton().trigger('click'); - expect(wrapper.emitted()).toEqual({ - onClick: [[]], + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.emitted()).toEqual({ + onClick: [[]], + }); }); }); diff --git a/spec/frontend/notes/components/discussion_resolve_button_spec.js b/spec/frontend/notes/components/discussion_resolve_button_spec.js index 1fae19f4492501b6296dc75413eb8874e84f2c27..c64e299efc383e76fc9f820a842bac398e063332 100644 --- a/spec/frontend/notes/components/discussion_resolve_button_spec.js +++ b/spec/frontend/notes/components/discussion_resolve_button_spec.js @@ -1,16 +1,13 @@ -import { createLocalVue, shallowMount } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; import resolveDiscussionButton from '~/notes/components/discussion_resolve_button.vue'; const buttonTitle = 'Resolve discussion'; describe('resolveDiscussionButton', () => { let wrapper; - let localVue; const factory = options => { - localVue = createLocalVue(); wrapper = shallowMount(resolveDiscussionButton, { - localVue, ...options, }); }; @@ -33,8 +30,10 @@ describe('resolveDiscussionButton', () => { button.trigger('click'); - expect(wrapper.emitted()).toEqual({ - onClick: [[]], + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.emitted()).toEqual({ + onClick: [[]], + }); }); }); @@ -67,7 +66,7 @@ describe('resolveDiscussionButton', () => { const button = wrapper.find({ ref: 'isResolvingIcon' }); - localVue.nextTick(() => { + wrapper.vm.$nextTick(() => { expect(button.exists()).toEqual(false); }); }); diff --git a/spec/frontend/notes/components/note_app_spec.js b/spec/frontend/notes/components/note_app_spec.js index 3c960adb69855ba1e3dfcdadacda193e3f15d6ee..f9b69e72619e18f028e2e2bc853d312f0174a5bc 100644 --- a/spec/frontend/notes/components/note_app_spec.js +++ b/spec/frontend/notes/components/note_app_spec.js @@ -1,7 +1,7 @@ import $ from 'helpers/jquery'; import AxiosMockAdapter from 'axios-mock-adapter'; import Vue from 'vue'; -import { mount, createLocalVue } from '@vue/test-utils'; +import { mount } from '@vue/test-utils'; import { setTestTimeout } from 'helpers/timeout'; import axios from '~/lib/utils/axios_utils'; import NotesApp from '~/notes/components/notes_app.vue'; @@ -48,7 +48,6 @@ describe('note_app', () => { notesData: mockData.notesDataMock, userData: mockData.userDataMock, }; - const localVue = createLocalVue(); return mount( { @@ -60,11 +59,8 @@ describe('note_app', () => { </div>`, }, { - attachToDocument: true, propsData, store, - localVue, - sync: false, }, ); }; @@ -290,7 +286,10 @@ describe('note_app', () => { it('should not render quick actions docs url', () => { wrapper.find('.js-note-edit').trigger('click'); const { quickActionsDocsPath } = mockData.notesDataMock; - expect(wrapper.find(`.edit-note a[href="${quickActionsDocsPath}"]`).exists()).toBe(false); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.find(`.edit-note a[href="${quickActionsDocsPath}"]`).exists()).toBe(false); + }); }); }); diff --git a/spec/frontend/notes/components/note_edited_text_spec.js b/spec/frontend/notes/components/note_edited_text_spec.js index e8d5a24e86adb3765798dee4c3eb541c802b2de1..0a5fe48ef94561290878cb5870e3fc42e9bddb4d 100644 --- a/spec/frontend/notes/components/note_edited_text_spec.js +++ b/spec/frontend/notes/components/note_edited_text_spec.js @@ -1,7 +1,6 @@ -import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; import NoteEditedText from '~/notes/components/note_edited_text.vue'; -const localVue = createLocalVue(); const propsData = { actionText: 'Edited', className: 'foo-bar', @@ -21,10 +20,7 @@ describe('NoteEditedText', () => { beforeEach(() => { wrapper = shallowMount(NoteEditedText, { - localVue, propsData, - sync: false, - attachToDocument: true, }); }); diff --git a/spec/javascripts/notes/components/note_header_spec.js b/spec/frontend/notes/components/note_header_spec.js similarity index 98% rename from spec/javascripts/notes/components/note_header_spec.js rename to spec/frontend/notes/components/note_header_spec.js index 6d1a7ef370f4b96d146208c4a37a4a45f38b1c6a..9b4323876549de83c88a13ecc37b4b72a1ac23ee 100644 --- a/spec/javascripts/notes/components/note_header_spec.js +++ b/spec/frontend/notes/components/note_header_spec.js @@ -90,7 +90,7 @@ describe('note_header component', () => { }); it('emits toggle event on click', done => { - spyOn(vm, '$emit'); + jest.spyOn(vm, '$emit').mockImplementation(() => {}); vm.$el.querySelector('.js-vue-toggle-button').click(); diff --git a/spec/javascripts/notes/stores/getters_spec.js b/spec/frontend/notes/stores/getters_spec.js similarity index 99% rename from spec/javascripts/notes/stores/getters_spec.js rename to spec/frontend/notes/stores/getters_spec.js index d69f469c7c72ffcadfb831678cde23ad6ff9806c..83417bd70efa32540c31d57b08242839717cdc82 100644 --- a/spec/javascripts/notes/stores/getters_spec.js +++ b/spec/frontend/notes/stores/getters_spec.js @@ -327,7 +327,7 @@ describe('Getters Notes Store', () => { beforeEach(() => { neighbor = {}; - findUnresolvedDiscussionIdNeighbor = jasmine.createSpy().and.returnValue(neighbor); + findUnresolvedDiscussionIdNeighbor = jest.fn(() => neighbor); localGetters = { findUnresolvedDiscussionIdNeighbor }; }); diff --git a/spec/javascripts/notes/stores/mutation_spec.js b/spec/frontend/notes/stores/mutation_spec.js similarity index 99% rename from spec/javascripts/notes/stores/mutation_spec.js rename to spec/frontend/notes/stores/mutation_spec.js index ade4725dd68af0c1bf9d23fef4e0c90fb2374d13..49debe348e200f7920d126ee88af69fba613b227 100644 --- a/spec/javascripts/notes/stores/mutation_spec.js +++ b/spec/frontend/notes/stores/mutation_spec.js @@ -498,7 +498,7 @@ describe('Notes Store mutations', () => { mutations.UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS(state); expect(state).toEqual( - jasmine.objectContaining({ + expect.objectContaining({ resolvableDiscussionsCount: 1, unresolvedDiscussionsCount: 1, hasUnresolvedDiscussions: false, @@ -535,7 +535,7 @@ describe('Notes Store mutations', () => { mutations.UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS(state); expect(state).toEqual( - jasmine.objectContaining({ + expect.objectContaining({ resolvableDiscussionsCount: 4, unresolvedDiscussionsCount: 2, hasUnresolvedDiscussions: true, diff --git a/spec/frontend/operation_settings/components/external_dashboard_spec.js b/spec/frontend/operation_settings/components/external_dashboard_spec.js index bb6e029c8082b30f4a244acf1788460c331a56a7..89db03378db9918d8bf5ecb7a70956b3c46840e0 100644 --- a/spec/frontend/operation_settings/components/external_dashboard_spec.js +++ b/spec/frontend/operation_settings/components/external_dashboard_spec.js @@ -1,4 +1,4 @@ -import { mount, shallowMount, createLocalVue } from '@vue/test-utils'; +import { mount, shallowMount } from '@vue/test-utils'; import { GlButton, GlLink, GlFormGroup, GlFormInput } from '@gitlab/ui'; import { TEST_HOST } from 'helpers/test_constants'; import ExternalDashboard from '~/operation_settings/components/external_dashboard.vue'; @@ -15,12 +15,10 @@ describe('operation settings external dashboard component', () => { const operationsSettingsEndpoint = `${TEST_HOST}/mock/ops/settings/endpoint`; const externalDashboardUrl = `http://mock-external-domain.com/external/dashboard/url`; const externalDashboardHelpPagePath = `${TEST_HOST}/help/page/path`; - const localVue = createLocalVue(); const mountComponent = (shallow = true) => { const config = [ ExternalDashboard, { - localVue, store: store({ operationsSettingsEndpoint, externalDashboardUrl, diff --git a/spec/frontend/pages/admin/users/components/__snapshots__/delete_user_modal_spec.js.snap b/spec/frontend/pages/admin/users/components/__snapshots__/delete_user_modal_spec.js.snap index 78a736a9060cbf040713bb929018d97048de9f85..d5ce2c1ee242f1c7d2de7226777d3936413aa3e8 100644 --- a/spec/frontend/pages/admin/users/components/__snapshots__/delete_user_modal_spec.js.snap +++ b/spec/frontend/pages/admin/users/components/__snapshots__/delete_user_modal_spec.js.snap @@ -29,7 +29,7 @@ exports[`User Operation confirmation modal renders modal with form included 1`] value="csrf" /> - <glforminput-stub + <gl-form-input-stub autocomplete="off" autofocus="" name="username" @@ -38,26 +38,26 @@ exports[`User Operation confirmation modal renders modal with form included 1`] /> </form> - <glbutton-stub + <gl-button-stub variant="secondary" > Cancel - </glbutton-stub> + </gl-button-stub> - <glbutton-stub + <gl-button-stub disabled="true" variant="warning" > secondaryAction - </glbutton-stub> + </gl-button-stub> - <glbutton-stub + <gl-button-stub disabled="true" variant="danger" > action - </glbutton-stub> + </gl-button-stub> </div> `; diff --git a/spec/frontend/pages/admin/users/components/__snapshots__/user_operation_confirmation_modal_spec.js.snap b/spec/frontend/pages/admin/users/components/__snapshots__/user_operation_confirmation_modal_spec.js.snap index 4a3989f51925c5fdea24baf200ec2be3e94d0b78..4b4e9997953e6d38e23a55188094b34728ff8fc0 100644 --- a/spec/frontend/pages/admin/users/components/__snapshots__/user_operation_confirmation_modal_spec.js.snap +++ b/spec/frontend/pages/admin/users/components/__snapshots__/user_operation_confirmation_modal_spec.js.snap @@ -1,7 +1,7 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`User Operation confirmation modal renders modal with form included 1`] = ` -<glmodal-stub +<gl-modal-stub modalclass="" modalid="user-operation-modal" ok-title="action" @@ -29,5 +29,5 @@ exports[`User Operation confirmation modal renders modal with form included 1`] value="csrf" /> </form> -</glmodal-stub> +</gl-modal-stub> `; diff --git a/spec/frontend/pages/admin/users/components/delete_user_modal_spec.js b/spec/frontend/pages/admin/users/components/delete_user_modal_spec.js index 3efebc690118175d6ec43dd41d4f9bacff98b66d..3efefa8137f906a54c1c8605ee898ae4219458ad 100644 --- a/spec/frontend/pages/admin/users/components/delete_user_modal_spec.js +++ b/spec/frontend/pages/admin/users/components/delete_user_modal_spec.js @@ -48,7 +48,6 @@ describe('User Operation confirmation modal', () => { stubs: { GlModal: ModalStub, }, - sync: false, }); }; diff --git a/spec/frontend/pages/admin/users/components/user_modal_manager_spec.js b/spec/frontend/pages/admin/users/components/user_modal_manager_spec.js index c88a182660d784ce23ec39ac630bb1735587cee1..3d615d9d05fd8b9cd050bd66ea46abbf72509747 100644 --- a/spec/frontend/pages/admin/users/components/user_modal_manager_spec.js +++ b/spec/frontend/pages/admin/users/components/user_modal_manager_spec.js @@ -1,4 +1,4 @@ -import { shallowMount } from '@vue/test-utils'; +import { mount } from '@vue/test-utils'; import UserModalManager from '~/pages/admin/users/components/user_modal_manager.vue'; import ModalStub from './stubs/modal_stub'; @@ -22,18 +22,13 @@ describe('Users admin page Modal Manager', () => { let wrapper; const createComponent = (props = {}) => { - wrapper = shallowMount(UserModalManager, { + wrapper = mount(UserModalManager, { propsData: { actionModals, modalConfiguration, csrfToken: 'dummyCSRF', ...props, }, - stubs: { - dummyComponent1: true, - dummyComponent2: true, - }, - sync: false, }); }; diff --git a/spec/frontend/pages/admin/users/components/user_operation_confirmation_modal_spec.js b/spec/frontend/pages/admin/users/components/user_operation_confirmation_modal_spec.js index 0ecdae2618c7b977e1c1e80b367abfaf39029998..f3a37a255cd4ea6107a4de03f94dc89981cd436b 100644 --- a/spec/frontend/pages/admin/users/components/user_operation_confirmation_modal_spec.js +++ b/spec/frontend/pages/admin/users/components/user_operation_confirmation_modal_spec.js @@ -17,7 +17,6 @@ describe('User Operation confirmation modal', () => { method: 'method', ...props, }, - sync: false, }); }; diff --git a/spec/javascripts/pages/projects/pipeline_schedules/shared/components/timezone_dropdown_spec.js b/spec/frontend/pages/projects/pipeline_schedules/shared/components/timezone_dropdown_spec.js similarity index 97% rename from spec/javascripts/pages/projects/pipeline_schedules/shared/components/timezone_dropdown_spec.js rename to spec/frontend/pages/projects/pipeline_schedules/shared/components/timezone_dropdown_spec.js index 5f4dba5ecb96f0d566a0d03271a041d8c048af0d..8917251d285eed9b915273f8eba0bb68fe1d4d4a 100644 --- a/spec/javascripts/pages/projects/pipeline_schedules/shared/components/timezone_dropdown_spec.js +++ b/spec/frontend/pages/projects/pipeline_schedules/shared/components/timezone_dropdown_spec.js @@ -6,7 +6,7 @@ import TimezoneDropdown, { findTimezoneByIdentifier, } from '~/pages/projects/pipeline_schedules/shared/components/timezone_dropdown'; -describe('Timezone Dropdown', function() { +describe('Timezone Dropdown', () => { preloadFixtures('pipeline_schedules/edit.html'); let $inputEl = null; @@ -81,7 +81,7 @@ describe('Timezone Dropdown', function() { }); it('will call a provided handler when a new timezone is selected', () => { - const onSelectTimezone = jasmine.createSpy('onSelectTimezoneMock'); + const onSelectTimezone = jest.fn(); // eslint-disable-next-line no-new new TimezoneDropdown({ $inputEl, @@ -111,7 +111,7 @@ describe('Timezone Dropdown', function() { }); it('will call a provided `displayFormat` handler to format the dropdown value', () => { - const displayFormat = jasmine.createSpy('displayFormat'); + const displayFormat = jest.fn(); // eslint-disable-next-line no-new new TimezoneDropdown({ $inputEl, diff --git a/spec/frontend/performance_bar/components/add_request_spec.js b/spec/frontend/performance_bar/components/add_request_spec.js index a0ad25744b08684f344cf30baf26a4a480c771df..c5247a43f27e1f59ef7e43c39bcc4c2b665b9b4f 100644 --- a/spec/frontend/performance_bar/components/add_request_spec.js +++ b/spec/frontend/performance_bar/components/add_request_spec.js @@ -19,6 +19,7 @@ describe('add request form', () => { describe('when clicking the button', () => { beforeEach(() => { wrapper.find('button').trigger('click'); + return wrapper.vm.$nextTick(); }); it('shows the form', () => { @@ -28,6 +29,7 @@ describe('add request form', () => { describe('when pressing escape', () => { beforeEach(() => { wrapper.find('input').trigger('keyup.esc'); + return wrapper.vm.$nextTick(); }); it('hides the input', () => { @@ -38,7 +40,10 @@ describe('add request form', () => { describe('when submitting the form', () => { beforeEach(() => { wrapper.find('input').setValue('http://gitlab.example.com/users/root/calendar.json'); - wrapper.find('input').trigger('keyup.enter'); + return wrapper.vm.$nextTick().then(() => { + wrapper.find('input').trigger('keyup.enter'); + return wrapper.vm.$nextTick(); + }); }); it('emits an event to add the request', () => { @@ -54,8 +59,9 @@ describe('add request form', () => { it('clears the value for next time', () => { wrapper.find('button').trigger('click'); - - expect(wrapper.find('input').text()).toEqual(''); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.find('input').text()).toEqual(''); + }); }); }); }); diff --git a/spec/frontend/pipelines/graph/action_component_spec.js b/spec/frontend/pipelines/graph/action_component_spec.js index a8fddd5fff2a13663dd00932a21173dbe3c35830..43da6388efac9473275dae11d32aa79b86dce721 100644 --- a/spec/frontend/pipelines/graph/action_component_spec.js +++ b/spec/frontend/pipelines/graph/action_component_spec.js @@ -19,8 +19,6 @@ describe('pipeline graph action component', () => { link: 'foo', actionIcon: 'cancel', }, - sync: false, - attachToDocument: true, }); }); @@ -30,7 +28,7 @@ describe('pipeline graph action component', () => { }); it('should render the provided title as a bootstrap tooltip', () => { - expect(wrapper.attributes('data-original-title')).toBe('bar'); + expect(wrapper.attributes('title')).toBe('bar'); }); it('should update bootstrap tooltip when title changes', done => { @@ -39,7 +37,7 @@ describe('pipeline graph action component', () => { wrapper.vm .$nextTick() .then(() => { - expect(wrapper.attributes('data-original-title')).toBe('changed'); + expect(wrapper.attributes('title')).toBe('changed'); }) .then(done) .catch(done.fail); diff --git a/spec/frontend/pipelines/graph/job_item_spec.js b/spec/frontend/pipelines/graph/job_item_spec.js index c79af95b3f34223f0f042d7c70f850b0826669e9..0c64d5c9fa883c55b60455dcea7249034ae9f9ee 100644 --- a/spec/frontend/pipelines/graph/job_item_spec.js +++ b/spec/frontend/pipelines/graph/job_item_spec.js @@ -6,7 +6,9 @@ describe('pipeline graph job item', () => { let wrapper; const createWrapper = propsData => { - wrapper = mount(JobItem, { sync: false, attachToDocument: true, propsData }); + wrapper = mount(JobItem, { + propsData, + }); }; const delayedJobFixture = getJSONFixture('jobs/delayed.json'); @@ -43,9 +45,7 @@ describe('pipeline graph job item', () => { expect(link.attributes('href')).toBe(mockJob.status.details_path); - expect(link.attributes('data-original-title')).toEqual( - `${mockJob.name} - ${mockJob.status.label}`, - ); + expect(link.attributes('title')).toEqual(`${mockJob.name} - ${mockJob.status.label}`); expect(wrapper.find('.js-status-icon-success')).toBeDefined(); @@ -110,9 +110,7 @@ describe('pipeline graph job item', () => { }, }); - expect(wrapper.find('.js-job-component-tooltip').attributes('data-original-title')).toBe( - 'test', - ); + expect(wrapper.find('.js-job-component-tooltip').attributes('title')).toBe('test'); }); it('should not render status label when it is provided', () => { @@ -128,7 +126,7 @@ describe('pipeline graph job item', () => { }, }); - expect(wrapper.find('.js-job-component-tooltip').attributes('data-original-title')).toEqual( + expect(wrapper.find('.js-job-component-tooltip').attributes('title')).toEqual( 'test - success', ); }); @@ -140,7 +138,7 @@ describe('pipeline graph job item', () => { job: delayedJobFixture, }); - expect(wrapper.find('.js-pipeline-graph-job-link').attributes('data-original-title')).toEqual( + expect(wrapper.find('.js-pipeline-graph-job-link').attributes('title')).toEqual( `delayed job - delayed manual action (${wrapper.vm.remainingTime})`, ); }); diff --git a/spec/frontend/pipelines/graph/linked_pipeline_spec.js b/spec/frontend/pipelines/graph/linked_pipeline_spec.js index c355d6532030dc9ca21ccf93060d75b4df7d6057..7f49b21100d2f1d46ec8e2c5a971f318a5102257 100644 --- a/spec/frontend/pipelines/graph/linked_pipeline_spec.js +++ b/spec/frontend/pipelines/graph/linked_pipeline_spec.js @@ -8,6 +8,12 @@ const mockPipeline = mockData.triggered[0]; describe('Linked pipeline', () => { let wrapper; + const createWrapper = propsData => { + wrapper = mount(LinkedPipelineComponent, { + propsData, + }); + }; + afterEach(() => { wrapper.destroy(); }); @@ -15,14 +21,12 @@ describe('Linked pipeline', () => { describe('rendered output', () => { const props = { pipeline: mockPipeline, + projectId: 20, + columnTitle: 'Downstream', }; beforeEach(() => { - wrapper = mount(LinkedPipelineComponent, { - sync: false, - attachToDocument: true, - propsData: props, - }); + createWrapper(props); }); it('should render a list item as the containing element', () => { @@ -65,7 +69,7 @@ describe('Linked pipeline', () => { it('should render the tooltip text as the title attribute', () => { const tooltipRef = wrapper.find('.js-linked-pipeline-content'); - const titleAttr = tooltipRef.attributes('data-original-title'); + const titleAttr = tooltipRef.attributes('title'); expect(titleAttr).toContain(mockPipeline.project.name); expect(titleAttr).toContain(mockPipeline.details.status.label); @@ -74,19 +78,50 @@ describe('Linked pipeline', () => { it('does not render the loading icon when isLoading is false', () => { expect(wrapper.find('.js-linked-pipeline-loading').exists()).toBe(false); }); + + it('should not display child label when pipeline project id is not the same as triggered pipeline project id', () => { + const labelContainer = wrapper.find('.parent-child-label-container'); + expect(labelContainer.exists()).toBe(false); + }); + }); + + describe('parent/child', () => { + const downstreamProps = { + pipeline: mockPipeline, + projectId: 19, + columnTitle: 'Downstream', + }; + + const upstreamProps = { + ...downstreamProps, + columnTitle: 'Upstream', + }; + + it('parent/child label container should exist', () => { + createWrapper(downstreamProps); + expect(wrapper.find('.parent-child-label-container').exists()).toBe(true); + }); + + it('should display child label when pipeline project id is the same as triggered pipeline project id', () => { + createWrapper(downstreamProps); + expect(wrapper.find('.parent-child-label-container').text()).toContain('Child'); + }); + + it('should display parent label when pipeline project id is the same as triggered_by pipeline project id', () => { + createWrapper(upstreamProps); + expect(wrapper.find('.parent-child-label-container').text()).toContain('Parent'); + }); }); describe('when isLoading is true', () => { const props = { pipeline: { ...mockPipeline, isLoading: true }, + projectId: 19, + columnTitle: 'Downstream', }; beforeEach(() => { - wrapper = mount(LinkedPipelineComponent, { - sync: false, - attachToDocument: true, - propsData: props, - }); + createWrapper(props); }); it('renders a loading icon', () => { @@ -97,21 +132,19 @@ describe('Linked pipeline', () => { describe('on click', () => { const props = { pipeline: mockPipeline, + projectId: 19, + columnTitle: 'Downstream', }; beforeEach(() => { - wrapper = mount(LinkedPipelineComponent, { - sync: false, - attachToDocument: true, - propsData: props, - }); + createWrapper(props); }); it('emits `pipelineClicked` event', () => { jest.spyOn(wrapper.vm, '$emit'); wrapper.find('button').trigger('click'); - expect(wrapper.vm.$emit).toHaveBeenCalledWith('pipelineClicked'); + expect(wrapper.emitted().pipelineClicked).toBeTruthy(); }); it('should emit `bv::hide::tooltip` to close the tooltip', () => { diff --git a/spec/frontend/pipelines/graph/linked_pipelines_mock_data.js b/spec/frontend/pipelines/graph/linked_pipelines_mock_data.js index f794b8484a7cb5044159ce3176e62bfe4112c974..c9a94b3101f373e55c8c0d34d203c0517c5b8775 100644 --- a/spec/frontend/pipelines/graph/linked_pipelines_mock_data.js +++ b/spec/frontend/pipelines/graph/linked_pipelines_mock_data.js @@ -1,4 +1,7 @@ export default { + project: { + id: 19, + }, triggered_by: { id: 129, active: true, @@ -63,6 +66,7 @@ export default { path: '/gitlab-org/gitlab-foss/pipelines/132', project: { name: 'GitLabCE', + id: 19, }, details: { status: { diff --git a/spec/javascripts/pipelines/nav_controls_spec.js b/spec/frontend/pipelines/nav_controls_spec.js similarity index 97% rename from spec/javascripts/pipelines/nav_controls_spec.js rename to spec/frontend/pipelines/nav_controls_spec.js index 7806cdf1477327769b6ceb370186fa8cc7167073..6d28da0ea2ae5f2d12dc000e3d0bb0bd2ace2b8d 100644 --- a/spec/javascripts/pipelines/nav_controls_spec.js +++ b/spec/frontend/pipelines/nav_controls_spec.js @@ -75,7 +75,7 @@ describe('Pipelines Nav Controls', () => { }); it('should emit postAction event when reset runner cache button is clicked', () => { - spyOn(component, '$emit'); + jest.spyOn(component, '$emit').mockImplementation(() => {}); component.$el.querySelector('.js-clear-cache').click(); diff --git a/spec/frontend/pipelines/pipeline_triggerer_spec.js b/spec/frontend/pipelines/pipeline_triggerer_spec.js index e211852f74b16c628bfcc3e9f855afe7a401fbf3..a8eec274487c9083788fca9b0515d30a7c1044a5 100644 --- a/spec/frontend/pipelines/pipeline_triggerer_spec.js +++ b/spec/frontend/pipelines/pipeline_triggerer_spec.js @@ -24,8 +24,6 @@ describe('Pipelines Triggerer', () => { const createComponent = () => { wrapper = shallowMount(pipelineTriggerer, { propsData: mockData, - sync: false, - attachToDocument: true, }); }; diff --git a/spec/frontend/pipelines/pipeline_url_spec.js b/spec/frontend/pipelines/pipeline_url_spec.js index 3c0c35e1f0f1c670096c6e876f7d5eeaf9c0cdc4..70b94f2c8e162589aba09ecfa5ff4aaccbcd096e 100644 --- a/spec/frontend/pipelines/pipeline_url_spec.js +++ b/spec/frontend/pipelines/pipeline_url_spec.js @@ -10,8 +10,6 @@ describe('Pipeline Url Component', () => { const createComponent = props => { wrapper = shallowMount(PipelineUrlComponent, { - sync: false, - attachToDocument: true, propsData: props, }); }; @@ -105,8 +103,6 @@ describe('Pipeline Url Component', () => { }); expect(wrapper.find('.js-pipeline-url-failure').text()).toContain('error'); - expect(wrapper.find('.js-pipeline-url-failure').attributes('data-original-title')).toContain( - 'some reason', - ); + expect(wrapper.find('.js-pipeline-url-failure').attributes('title')).toContain('some reason'); }); }); diff --git a/spec/frontend/pipelines/pipelines_table_row_spec.js b/spec/frontend/pipelines/pipelines_table_row_spec.js index 1c785ec6ffee8cd1d7129a76f285b56e31d12733..c43210c5350ea3e0c0643ae773f436065523f99a 100644 --- a/spec/frontend/pipelines/pipelines_table_row_spec.js +++ b/spec/frontend/pipelines/pipelines_table_row_spec.js @@ -12,7 +12,6 @@ describe('Pipelines Table Row', () => { autoDevopsHelpPath: 'foo', viewType: 'root', }, - sync: false, }); let wrapper; diff --git a/spec/frontend/polyfills/element_spec.js b/spec/frontend/polyfills/element_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..64ce248ca44e7f245379150fe643aac3a421771e --- /dev/null +++ b/spec/frontend/polyfills/element_spec.js @@ -0,0 +1,46 @@ +import '~/commons/polyfills/element'; + +describe('Element polyfills', () => { + let testContext; + + beforeEach(() => { + testContext = {}; + }); + + beforeEach(() => { + testContext.element = document.createElement('ul'); + }); + + describe('matches', () => { + it('returns true if element matches the selector', () => { + expect(testContext.element.matches('ul')).toBeTruthy(); + }); + + it("returns false if element doesn't match the selector", () => { + expect(testContext.element.matches('.not-an-element')).toBeFalsy(); + }); + }); + + describe('closest', () => { + beforeEach(() => { + testContext.childElement = document.createElement('li'); + testContext.element.appendChild(testContext.childElement); + }); + + it('returns the closest parent that matches the selector', () => { + expect(testContext.childElement.closest('ul').toString()).toBe( + testContext.element.toString(), + ); + }); + + it('returns itself if it matches the selector', () => { + expect(testContext.childElement.closest('li').toString()).toBe( + testContext.childElement.toString(), + ); + }); + + it('returns undefined if nothing matches the selector', () => { + expect(testContext.childElement.closest('.no-an-element')).toBeFalsy(); + }); + }); +}); diff --git a/spec/javascripts/profile/add_ssh_key_validation_spec.js b/spec/frontend/profile/add_ssh_key_validation_spec.js similarity index 88% rename from spec/javascripts/profile/add_ssh_key_validation_spec.js rename to spec/frontend/profile/add_ssh_key_validation_spec.js index c71a2885acc380376e7e93e7aed7aa509564e624..1fec864599c53424949f79e0bb94111c2f924e13 100644 --- a/spec/javascripts/profile/add_ssh_key_validation_spec.js +++ b/spec/frontend/profile/add_ssh_key_validation_spec.js @@ -4,16 +4,18 @@ describe('AddSshKeyValidation', () => { describe('submit', () => { it('returns true if isValid is true', () => { const addSshKeyValidation = new AddSshKeyValidation({}); - spyOn(AddSshKeyValidation, 'isPublicKey').and.returnValue(true); + jest.spyOn(AddSshKeyValidation, 'isPublicKey').mockReturnValue(true); expect(addSshKeyValidation.submit()).toBeTruthy(); }); it('calls preventDefault and toggleWarning if isValid is false', () => { const addSshKeyValidation = new AddSshKeyValidation({}); - const event = jasmine.createSpyObj('event', ['preventDefault']); - spyOn(AddSshKeyValidation, 'isPublicKey').and.returnValue(false); - spyOn(addSshKeyValidation, 'toggleWarning'); + const event = { + preventDefault: jest.fn(), + }; + jest.spyOn(AddSshKeyValidation, 'isPublicKey').mockReturnValue(false); + jest.spyOn(addSshKeyValidation, 'toggleWarning').mockImplementation(() => {}); addSshKeyValidation.submit(event); diff --git a/spec/frontend/project_select_combo_button_spec.js b/spec/frontend/project_select_combo_button_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..c47db71b4ac3595dbe968bb0504ff272218a3c82 --- /dev/null +++ b/spec/frontend/project_select_combo_button_spec.js @@ -0,0 +1,140 @@ +import $ from 'jquery'; +import ProjectSelectComboButton from '~/project_select_combo_button'; + +const fixturePath = 'static/project_select_combo_button.html'; + +describe('Project Select Combo Button', () => { + let testContext; + + beforeEach(() => { + testContext = {}; + }); + + preloadFixtures(fixturePath); + + beforeEach(() => { + testContext.defaults = { + label: 'Select project to create issue', + groupId: 12345, + projectMeta: { + name: 'My Cool Project', + url: 'http://mycoolproject.com', + }, + newProjectMeta: { + name: 'My Other Cool Project', + url: 'http://myothercoolproject.com', + }, + localStorageKey: 'group-12345-new-issue-recent-project', + relativePath: 'issues/new', + }; + + loadFixtures(fixturePath); + + testContext.newItemBtn = document.querySelector('.new-project-item-link'); + testContext.projectSelectInput = document.querySelector('.project-item-select'); + }); + + describe('on page load when localStorage is empty', () => { + beforeEach(() => { + testContext.comboButton = new ProjectSelectComboButton(testContext.projectSelectInput); + }); + + it('newItemBtn href is null', () => { + expect(testContext.newItemBtn.getAttribute('href')).toBe(''); + }); + + it('newItemBtn text is the plain default label', () => { + expect(testContext.newItemBtn.textContent).toBe(testContext.defaults.label); + }); + }); + + describe('on page load when localStorage is filled', () => { + beforeEach(() => { + window.localStorage.setItem( + testContext.defaults.localStorageKey, + JSON.stringify(testContext.defaults.projectMeta), + ); + testContext.comboButton = new ProjectSelectComboButton(testContext.projectSelectInput); + }); + + it('newItemBtn href is correctly set', () => { + expect(testContext.newItemBtn.getAttribute('href')).toBe( + testContext.defaults.projectMeta.url, + ); + }); + + it('newItemBtn text is the cached label', () => { + expect(testContext.newItemBtn.textContent).toBe( + `New issue in ${testContext.defaults.projectMeta.name}`, + ); + }); + + afterEach(() => { + window.localStorage.clear(); + }); + }); + + describe('after selecting a new project', () => { + beforeEach(() => { + testContext.comboButton = new ProjectSelectComboButton(testContext.projectSelectInput); + + // mock the effect of selecting an item from the projects dropdown (select2) + $('.project-item-select') + .val(JSON.stringify(testContext.defaults.newProjectMeta)) + .trigger('change'); + }); + + it('newItemBtn href is correctly set', () => { + expect(testContext.newItemBtn.getAttribute('href')).toBe( + 'http://myothercoolproject.com/issues/new', + ); + }); + + it('newItemBtn text is the selected project label', () => { + expect(testContext.newItemBtn.textContent).toBe( + `New issue in ${testContext.defaults.newProjectMeta.name}`, + ); + }); + + afterEach(() => { + window.localStorage.clear(); + }); + }); + + describe('deriveTextVariants', () => { + beforeEach(() => { + testContext.mockExecutionContext = { + resourceType: '', + resourceLabel: '', + }; + + testContext.comboButton = new ProjectSelectComboButton(testContext.projectSelectInput); + + testContext.method = testContext.comboButton.deriveTextVariants.bind( + testContext.mockExecutionContext, + ); + }); + + it('correctly derives test variants for merge requests', () => { + testContext.mockExecutionContext.resourceType = 'merge_requests'; + testContext.mockExecutionContext.resourceLabel = 'New merge request'; + + const returnedVariants = testContext.method(); + + expect(returnedVariants.localStorageItemType).toBe('new-merge-request'); + expect(returnedVariants.defaultTextPrefix).toBe('New merge request'); + expect(returnedVariants.presetTextSuffix).toBe('merge request'); + }); + + it('correctly derives text variants for issues', () => { + testContext.mockExecutionContext.resourceType = 'issues'; + testContext.mockExecutionContext.resourceLabel = 'New issue'; + + const returnedVariants = testContext.method(); + + expect(returnedVariants.localStorageItemType).toBe('new-issue'); + expect(returnedVariants.defaultTextPrefix).toBe('New issue'); + expect(returnedVariants.presetTextSuffix).toBe('issue'); + }); + }); +}); diff --git a/spec/frontend/registry/list/components/__snapshots__/project_empty_state_spec.js.snap b/spec/frontend/registry/list/components/__snapshots__/project_empty_state_spec.js.snap index 3084462f5aea007e501bf4f7db8c130fc5811a5c..d11a9bdeb51b60dd30d24e23a1857b13d48f5c17 100644 --- a/spec/frontend/registry/list/components/__snapshots__/project_empty_state_spec.js.snap +++ b/spec/frontend/registry/list/components/__snapshots__/project_empty_state_spec.js.snap @@ -86,8 +86,7 @@ exports[`Registry Project Empty state to match the default snapshot 1`] = ` <button class="btn input-group-text btn-secondary btn-default" data-clipboard-text="docker login host" - data-original-title="Copy login command" - title="" + title="Copy login command" type="button" > <svg @@ -125,8 +124,7 @@ exports[`Registry Project Empty state to match the default snapshot 1`] = ` <button class="btn input-group-text btn-secondary btn-default" data-clipboard-text="docker build -t url ." - data-original-title="Copy build command" - title="" + title="Copy build command" type="button" > <svg @@ -156,8 +154,7 @@ exports[`Registry Project Empty state to match the default snapshot 1`] = ` <button class="btn input-group-text btn-secondary btn-default" data-clipboard-text="docker push url" - data-original-title="Copy push command" - title="" + title="Copy push command" type="button" > <svg diff --git a/spec/frontend/registry/list/components/app_spec.js b/spec/frontend/registry/list/components/app_spec.js index 5072a285f835de5dff29e077751a1538ec79fbb4..c2c220b2cd2075fb604442f335c7298a6e0df27c 100644 --- a/spec/frontend/registry/list/components/app_spec.js +++ b/spec/frontend/registry/list/components/app_spec.js @@ -1,4 +1,3 @@ -import Vue from 'vue'; import { mount } from '@vue/test-utils'; import { TEST_HOST } from 'helpers/test_constants'; import registry from '~/registry/list/components/app.vue'; @@ -35,12 +34,7 @@ describe('Registry List', () => { }; beforeEach(() => { - // This is needed due to console.error called by vue to emit a warning that stop the tests. - // See https://github.com/vuejs/vue-test-utils/issues/532. - Vue.config.silent = true; wrapper = mount(registry, { - attachToDocument: true, - sync: false, propsData, computed: { repos() { @@ -52,7 +46,6 @@ describe('Registry List', () => { }); afterEach(() => { - Vue.config.silent = false; wrapper.destroy(); }); @@ -67,8 +60,6 @@ describe('Registry List', () => { describe('without data', () => { beforeEach(() => { wrapper = mount(registry, { - attachToDocument: true, - sync: false, propsData, computed: { repos() { @@ -138,7 +129,7 @@ describe('Registry List', () => { wrapper = mount(registry, { propsData: { ...propsData, - endpoint: null, + endpoint: '', isGroupPage, }, methods, @@ -146,7 +137,7 @@ describe('Registry List', () => { }); it('call the right vuex setters', () => { - expect(methods.setMainEndpoint).toHaveBeenLastCalledWith(null); + expect(methods.setMainEndpoint).toHaveBeenLastCalledWith(''); expect(methods.setIsDeleteDisabled).toHaveBeenLastCalledWith(true); }); diff --git a/spec/frontend/registry/list/components/collapsible_container_spec.js b/spec/frontend/registry/list/components/collapsible_container_spec.js index cba49e72588e5de5c438f4fad8db66a284cf1217..f969f0ba9ba661354707f8b4835d7557d04534e9 100644 --- a/spec/frontend/registry/list/components/collapsible_container_spec.js +++ b/spec/frontend/registry/list/components/collapsible_container_spec.js @@ -1,4 +1,3 @@ -import Vue from 'vue'; import Vuex from 'vuex'; import { mount, createLocalVue } from '@vue/test-utils'; import createFlash from '~/flash'; @@ -27,15 +26,10 @@ describe('collapsible registry container', () => { ...config, store, localVue, - attachToDocument: true, - sync: false, }); beforeEach(() => { createFlash.mockClear(); - // This is needed due to console.error called by vue to emit a warning that stop the tests - // see https://github.com/vuejs/vue-test-utils/issues/532 - Vue.config.silent = true; store = new Vuex.Store({ state: { isDeleteDisabled: false, @@ -51,7 +45,6 @@ describe('collapsible registry container', () => { }); afterEach(() => { - Vue.config.silent = false; wrapper.destroy(); }); @@ -59,6 +52,7 @@ describe('collapsible registry container', () => { beforeEach(() => { const fetchList = jest.fn(); wrapper.setMethods({ fetchList }); + return wrapper.vm.$nextTick(); }); const expectIsClosed = () => { @@ -71,44 +65,54 @@ describe('collapsible registry container', () => { expectIsClosed(); }); - it('should be open when user clicks on closed repo', done => { + it('should be open when user clicks on closed repo', () => { const toggleRepos = findToggleRepos(); toggleRepos.at(0).trigger('click'); - Vue.nextTick(() => { + return wrapper.vm.$nextTick().then(() => { const container = findContainerImageTags(); expect(container.exists()).toBe(true); expect(wrapper.vm.fetchList).toHaveBeenCalled(); - done(); }); }); - it('should be closed when the user clicks on an opened repo', done => { + it('should be closed when the user clicks on an opened repo', () => { const toggleRepos = findToggleRepos(); toggleRepos.at(0).trigger('click'); - Vue.nextTick(() => { + return wrapper.vm.$nextTick().then(() => { toggleRepos.at(0).trigger('click'); - Vue.nextTick(() => { + wrapper.vm.$nextTick(() => { expectIsClosed(); - done(); }); }); }); }); describe('delete repo', () => { + beforeEach(() => { + const deleteItem = jest.fn().mockResolvedValue(); + const fetchRepos = jest.fn().mockResolvedValue(); + wrapper.setMethods({ deleteItem, fetchRepos }); + }); + it('should be possible to delete a repo', () => { const deleteBtn = findDeleteBtn(); expect(deleteBtn.exists()).toBe(true); }); it('should call deleteItem when confirming deletion', () => { - const deleteItem = jest.fn().mockResolvedValue(); - const fetchRepos = jest.fn().mockResolvedValue(); - wrapper.setMethods({ deleteItem, fetchRepos }); wrapper.vm.handleDeleteRepository(); expect(wrapper.vm.deleteItem).toHaveBeenCalledWith(wrapper.vm.repo); }); + it('should show a flash with a success notice', () => + wrapper.vm.handleDeleteRepository().then(() => { + expect(wrapper.vm.deleteImageConfirmationMessage).toContain(wrapper.vm.repo.name); + expect(createFlash).toHaveBeenCalledWith( + wrapper.vm.deleteImageConfirmationMessage, + 'notice', + ); + })); + it('should show an error when there is API error', () => { const deleteItem = jest.fn().mockRejectedValue('error'); wrapper.setMethods({ deleteItem }); diff --git a/spec/frontend/registry/list/components/project_empty_state_spec.js b/spec/frontend/registry/list/components/project_empty_state_spec.js index bd717a4eb106e53404a26322af76a225de793ea4..d29b9e472338ac875420fed2611fe850667090f1 100644 --- a/spec/frontend/registry/list/components/project_empty_state_spec.js +++ b/spec/frontend/registry/list/components/project_empty_state_spec.js @@ -6,8 +6,6 @@ describe('Registry Project Empty state', () => { beforeEach(() => { wrapper = mount(projectEmptyState, { - attachToDocument: true, - sync: false, propsData: { noContainersImage: 'imageUrl', helpPagePath: 'help', diff --git a/spec/frontend/registry/list/components/table_registry_spec.js b/spec/frontend/registry/list/components/table_registry_spec.js index fe099adbdfb07d981eb1ff3d7ea692b2f869d049..b13797929ddf5fedceffa8a5f8f349666eb3540b 100644 --- a/spec/frontend/registry/list/components/table_registry_spec.js +++ b/spec/frontend/registry/list/components/table_registry_spec.js @@ -1,4 +1,3 @@ -import Vue from 'vue'; import Vuex from 'vuex'; import { mount, createLocalVue } from '@vue/test-utils'; import createFlash from '~/flash'; @@ -29,13 +28,13 @@ describe('table registry', () => { const bulkDeletePath = 'path'; const mountWithStore = config => - mount(tableRegistry, { ...config, store, localVue, attachToDocument: true, sync: false }); + mount(tableRegistry, { + ...config, + store, + localVue, + }); beforeEach(() => { - // This is needed due to console.error called by vue to emit a warning that stop the tests - // see https://github.com/vuejs/vue-test-utils/issues/532 - Vue.config.silent = true; - store = new Vuex.Store({ state: { isDeleteDisabled: false, @@ -52,7 +51,6 @@ describe('table registry', () => { }); afterEach(() => { - Vue.config.silent = false; wrapper.destroy(); }); @@ -82,67 +80,65 @@ describe('table registry', () => { }); describe('multi select', () => { - it('selecting a row should enable delete button', done => { + it('selecting a row should enable delete button', () => { const deleteBtn = findDeleteButton(); const checkboxes = findSelectCheckboxes(); expect(deleteBtn.attributes('disabled')).toBe('disabled'); checkboxes.at(0).trigger('click'); - Vue.nextTick(() => { + return wrapper.vm.$nextTick().then(() => { expect(deleteBtn.attributes('disabled')).toEqual(undefined); - done(); }); }); - it('selecting all checkbox should select all rows and enable delete button', done => { + it('selecting all checkbox should select all rows and enable delete button', () => { const selectAll = findSelectAllCheckbox(); - const checkboxes = findSelectCheckboxes(); selectAll.trigger('click'); - Vue.nextTick(() => { + return wrapper.vm.$nextTick().then(() => { + const checkboxes = findSelectCheckboxes(); const checked = checkboxes.filter(w => w.element.checked); expect(checked.length).toBe(checkboxes.length); - done(); }); }); - it('deselecting select all checkbox should deselect all rows and disable delete button', done => { + it('deselecting select all checkbox should deselect all rows and disable delete button', () => { const checkboxes = findSelectCheckboxes(); const selectAll = findSelectAllCheckbox(); selectAll.trigger('click'); selectAll.trigger('click'); - Vue.nextTick(() => { + return wrapper.vm.$nextTick().then(() => { const checked = checkboxes.filter(w => !w.element.checked); expect(checked.length).toBe(checkboxes.length); - done(); }); }); - it('should delete multiple items when multiple items are selected', done => { + it('should delete multiple items when multiple items are selected', () => { const multiDeleteItems = jest.fn().mockResolvedValue(); wrapper.setMethods({ multiDeleteItems }); - const selectAll = findSelectAllCheckbox(); - selectAll.trigger('click'); - Vue.nextTick(() => { - const deleteBtn = findDeleteButton(); - expect(wrapper.vm.selectedItems).toEqual([0, 1]); - expect(deleteBtn.attributes('disabled')).toEqual(undefined); - wrapper.setData({ itemsToBeDeleted: [...wrapper.vm.selectedItems] }); - wrapper.vm.handleMultipleDelete(); - - Vue.nextTick(() => { + return wrapper.vm + .$nextTick() + .then(() => { + const selectAll = findSelectAllCheckbox(); + selectAll.trigger('click'); + return wrapper.vm.$nextTick(); + }) + .then(() => { + const deleteBtn = findDeleteButton(); + expect(wrapper.vm.selectedItems).toEqual([0, 1]); + expect(deleteBtn.attributes('disabled')).toEqual(undefined); + wrapper.setData({ itemsToBeDeleted: [...wrapper.vm.selectedItems] }); + wrapper.vm.handleMultipleDelete(); expect(wrapper.vm.selectedItems).toEqual([]); expect(wrapper.vm.itemsToBeDeleted).toEqual([]); expect(wrapper.vm.multiDeleteItems).toHaveBeenCalledWith({ path: bulkDeletePath, items: [firstImage.tag, secondImage.tag], }); - done(); }); - }); }); it('should show an error message if bulkDeletePath is not set', () => { @@ -162,6 +158,7 @@ describe('table registry', () => { describe('delete registry', () => { beforeEach(() => { wrapper.setData({ selectedItems: [0] }); + return wrapper.vm.$nextTick(); }); it('should be possible to delete a registry', () => { @@ -178,10 +175,12 @@ describe('table registry', () => { const deleteSingleItem = jest.fn(); const deleteItem = jest.fn().mockResolvedValue(); wrapper.setMethods({ deleteSingleItem, deleteItem }); - deleteBtns.at(0).trigger('click'); - expect(wrapper.vm.deleteSingleItem).toHaveBeenCalledWith(0); - wrapper.vm.handleSingleDelete(1); - expect(wrapper.vm.deleteItem).toHaveBeenCalledWith(1); + return wrapper.vm.$nextTick().then(() => { + deleteBtns.at(0).trigger('click'); + expect(wrapper.vm.deleteSingleItem).toHaveBeenCalledWith(0); + wrapper.vm.handleSingleDelete(1); + expect(wrapper.vm.deleteItem).toHaveBeenCalledWith(1); + }); }); }); @@ -317,6 +316,7 @@ describe('table registry', () => { describe('single tag delete', () => { beforeEach(() => { wrapper.setData({ itemsToBeDeleted: [0] }); + return wrapper.vm.$nextTick(); }); it('send an event when delete button is clicked', () => { @@ -345,6 +345,7 @@ describe('table registry', () => { beforeEach(() => { const items = [0, 1, 2]; wrapper.setData({ itemsToBeDeleted: items, selectedItems: items }); + return wrapper.vm.$nextTick(); }); it('send an event when delete button is clicked', () => { diff --git a/spec/frontend/registry/settings/components/__snapshots__/registry_settings_app_spec.js.snap b/spec/frontend/registry/settings/components/__snapshots__/registry_settings_app_spec.js.snap index c6dbb1da8e91636e4a44268ef720d6adeb9f2cf2..966acdf52beb579148da82ee07bcfcd008f862da 100644 --- a/spec/frontend/registry/settings/components/__snapshots__/registry_settings_app_spec.js.snap +++ b/spec/frontend/registry/settings/components/__snapshots__/registry_settings_app_spec.js.snap @@ -1,10 +1,10 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Registry List renders 1`] = ` +exports[`Registry Settings App renders 1`] = ` <div> <p> - Tag retention policies are designed to: + Tag expiration policy is designed to: </p> @@ -20,14 +20,6 @@ exports[`Registry List renders 1`] = ` </li> </ul> - <p> - Read more about the - <a - href="foo" - target="_blank" - > - Container Registry tag retention policies - </a> - </p> + <settings-form-stub /> </div> `; diff --git a/spec/frontend/registry/settings/components/__snapshots__/settings_form_spec.js.snap b/spec/frontend/registry/settings/components/__snapshots__/settings_form_spec.js.snap new file mode 100644 index 0000000000000000000000000000000000000000..d26df308b97f54b5df6591febfae3a334800bb6f --- /dev/null +++ b/spec/frontend/registry/settings/components/__snapshots__/settings_form_spec.js.snap @@ -0,0 +1,181 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Settings Form renders 1`] = ` +<form> + <div + class="card" + > + <!----> + <div + class="card-header" + > + + Tag expiration policy + + </div> + <div + class="card-body" + > + <!----> + <!----> + + <glformgroup-stub + id="expiration-policy-toggle-group" + label="Expiration policy:" + label-align="right" + label-cols="3" + label-for="expiration-policy-toggle" + > + <div + class="d-flex align-items-start" + > + <gltoggle-stub + id="expiration-policy-toggle" + labeloff="Toggle Status: OFF" + labelon="Toggle Status: ON" + /> + + <span + class="mb-2 ml-1 lh-2" + > + Docker tag expiration policy is + <strong> + disabled + </strong> + </span> + </div> + </glformgroup-stub> + + <glformgroup-stub + id="expiration-policy-interval-group" + label="Expiration interval:" + label-align="right" + label-cols="3" + label-for="expiration-policy-interval" + > + <glformselect-stub + disabled="true" + id="expiration-policy-interval" + value="bar" + > + <option + value="foo" + > + + Foo + + </option> + <option + value="bar" + > + + Bar + + </option> + </glformselect-stub> + </glformgroup-stub> + + <glformgroup-stub + id="expiration-policy-schedule-group" + label="Expiration schedule:" + label-align="right" + label-cols="3" + label-for="expiration-policy-schedule" + > + <glformselect-stub + disabled="true" + id="expiration-policy-schedule" + value="bar" + > + <option + value="foo" + > + + Foo + + </option> + <option + value="bar" + > + + Bar + + </option> + </glformselect-stub> + </glformgroup-stub> + + <glformgroup-stub + id="expiration-policy-latest-group" + label="Number of tags to retain:" + label-align="right" + label-cols="3" + label-for="expiration-policy-latest" + > + <glformselect-stub + disabled="true" + id="expiration-policy-latest" + value="bar" + > + <option + value="foo" + > + + Foo + + </option> + <option + value="bar" + > + + Bar + + </option> + </glformselect-stub> + </glformgroup-stub> + + <glformgroup-stub + id="expiration-policy-name-matching-group" + invalid-feedback="The value of this input should be less than 255 characters" + label="Expire Docker tags that match this regex:" + label-align="right" + label-cols="3" + label-for="expiration-policy-name-matching" + > + <glformtextarea-stub + disabled="true" + id="expiration-policy-name-matching" + placeholder=".*" + trim="" + value="" + /> + </glformgroup-stub> + + </div> + <div + class="card-footer" + > + <div + class="d-flex justify-content-end" + > + <glbutton-stub + class="mr-2 d-block" + type="reset" + > + Cancel + </glbutton-stub> + + <glbutton-stub + class="d-block" + type="submit" + variant="success" + > + + Save expiration policy + + </glbutton-stub> + </div> + </div> + <!----> + </div> +</form> +`; diff --git a/spec/frontend/registry/settings/components/registry_settings_app_spec.js b/spec/frontend/registry/settings/components/registry_settings_app_spec.js index 666d970aa6bbdc33a982d1399874c1b8cc0c3dfb..448ff2b3be9b7297e2dd7f7d5b49352238b274de 100644 --- a/spec/frontend/registry/settings/components/registry_settings_app_spec.js +++ b/spec/frontend/registry/settings/components/registry_settings_app_spec.js @@ -1,29 +1,33 @@ import Vuex from 'vuex'; import { shallowMount, createLocalVue } from '@vue/test-utils'; import component from '~/registry/settings/components/registry_settings_app.vue'; -import { createStore } from '~/registry/settings/stores/'; +import { createStore } from '~/registry/settings/store/'; const localVue = createLocalVue(); localVue.use(Vuex); -describe('Registry List', () => { +describe('Registry Settings App', () => { let wrapper; let store; + let fetchSpy; - const helpPagePath = 'foo'; - const findHelpLink = () => wrapper.find({ ref: 'help-link' }).find('a'); + const findSettingsComponent = () => wrapper.find({ ref: 'settings-form' }); + const findLoadingComponent = () => wrapper.find({ ref: 'loading-icon' }); - const mountComponent = (options = {}) => - shallowMount(component, { - sync: false, + const mountComponent = (options = {}) => { + fetchSpy = jest.fn(); + wrapper = shallowMount(component, { store, + methods: { + fetchSettings: fetchSpy, + }, ...options, }); + }; beforeEach(() => { store = createStore(); - store.dispatch('setInitialState', { helpPagePath }); - wrapper = mountComponent(); + mountComponent(); }); afterEach(() => { @@ -34,7 +38,18 @@ describe('Registry List', () => { expect(wrapper.element).toMatchSnapshot(); }); - it('renders an help link dependant on the helphPagePath', () => { - expect(findHelpLink().attributes('href')).toBe(helpPagePath); + it('call the store function to load the data on mount', () => { + expect(fetchSpy).toHaveBeenCalled(); + }); + + it('renders a loader if isLoading is true', () => { + store.dispatch('toggleLoading'); + return wrapper.vm.$nextTick().then(() => { + expect(findLoadingComponent().exists()).toBe(true); + expect(findSettingsComponent().exists()).toBe(false); + }); + }); + it('renders the setting form', () => { + expect(findSettingsComponent().exists()).toBe(true); }); }); diff --git a/spec/frontend/registry/settings/components/settings_form_spec.js b/spec/frontend/registry/settings/components/settings_form_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..bd733e965a49d742ba1c5ad0e96c247f9f89d803 --- /dev/null +++ b/spec/frontend/registry/settings/components/settings_form_spec.js @@ -0,0 +1,169 @@ +import Vuex from 'vuex'; +import { mount, createLocalVue } from '@vue/test-utils'; +import stubChildren from 'helpers/stub_children'; +import component from '~/registry/settings/components/settings_form.vue'; +import { createStore } from '~/registry/settings/store/'; +import { NAME_REGEX_LENGTH } from '~/registry/settings/constants'; +import { stringifiedFormOptions } from '../mock_data'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('Settings Form', () => { + let wrapper; + let store; + let saveSpy; + let resetSpy; + + const findFormGroup = name => wrapper.find(`#expiration-policy-${name}-group`); + const findFormElements = (name, father = wrapper) => father.find(`#expiration-policy-${name}`); + const findCancelButton = () => wrapper.find({ ref: 'cancel-button' }); + const findSaveButton = () => wrapper.find({ ref: 'save-button' }); + const findForm = () => wrapper.find({ ref: 'form-element' }); + + const mountComponent = (options = {}) => { + saveSpy = jest.fn(); + resetSpy = jest.fn(); + wrapper = mount(component, { + stubs: { + ...stubChildren(component), + GlCard: false, + }, + store, + methods: { + saveSettings: saveSpy, + resetSettings: resetSpy, + }, + ...options, + }); + }; + + beforeEach(() => { + store = createStore(); + store.dispatch('setInitialState', stringifiedFormOptions); + mountComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders', () => { + expect(wrapper.element).toMatchSnapshot(); + }); + + describe.each` + elementName | modelName | value | disabledByToggle + ${'toggle'} | ${'enabled'} | ${true} | ${'not disabled'} + ${'interval'} | ${'older_than'} | ${'foo'} | ${'disabled'} + ${'schedule'} | ${'cadence'} | ${'foo'} | ${'disabled'} + ${'latest'} | ${'keep_n'} | ${'foo'} | ${'disabled'} + ${'name-matching'} | ${'name_regex'} | ${'foo'} | ${'disabled'} + `('$elementName form element', ({ elementName, modelName, value, disabledByToggle }) => { + let formGroup; + beforeEach(() => { + formGroup = findFormGroup(elementName); + }); + it(`${elementName} form group exist in the dom`, () => { + expect(formGroup.exists()).toBe(true); + }); + + it(`${elementName} form group has a label-for property`, () => { + expect(formGroup.attributes('label-for')).toBe(`expiration-policy-${elementName}`); + }); + + it(`${elementName} form group has a label-cols property`, () => { + expect(formGroup.attributes('label-cols')).toBe(`${wrapper.vm.$options.labelsConfig.cols}`); + }); + + it(`${elementName} form group has a label-align property`, () => { + expect(formGroup.attributes('label-align')).toBe(`${wrapper.vm.$options.labelsConfig.align}`); + }); + + it(`${elementName} form group contains an input element`, () => { + expect(findFormElements(elementName, formGroup).exists()).toBe(true); + }); + + it(`${elementName} form element change updated ${modelName} with ${value}`, () => { + const element = findFormElements(elementName, formGroup); + const modelUpdateEvent = element.vm.$options.model + ? element.vm.$options.model.event + : 'input'; + element.vm.$emit(modelUpdateEvent, value); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.vm[modelName]).toBe(value); + }); + }); + + it(`${elementName} is ${disabledByToggle} by enabled set to false`, () => { + store.dispatch('updateSettings', { enabled: false }); + const expectation = disabledByToggle === 'disabled' ? 'true' : undefined; + expect(findFormElements(elementName, formGroup).attributes('disabled')).toBe(expectation); + }); + }); + + describe('form actions', () => { + let form; + beforeEach(() => { + form = findForm(); + }); + it('cancel has type reset', () => { + expect(findCancelButton().attributes('type')).toBe('reset'); + }); + + it('form reset event call the appropriate function', () => { + form.trigger('reset'); + expect(resetSpy).toHaveBeenCalled(); + }); + + it('save has type submit', () => { + expect(findSaveButton().attributes('type')).toBe('submit'); + }); + + it('form submit event call the appropriate function', () => { + form.trigger('submit'); + expect(saveSpy).toHaveBeenCalled(); + }); + }); + + describe('form validation', () => { + describe(`when name regex is longer than ${NAME_REGEX_LENGTH}`, () => { + const invalidString = new Array(NAME_REGEX_LENGTH + 2).join(','); + beforeEach(() => { + store.dispatch('updateSettings', { name_regex: invalidString }); + }); + + it('save btn is disabled', () => { + expect(findSaveButton().attributes('disabled')).toBeTruthy(); + }); + + it('nameRegexState is false', () => { + expect(wrapper.vm.nameRegexState).toBe(false); + }); + }); + + it('if the user did not type validation is null', () => { + store.dispatch('updateSettings', { name_regex: null }); + expect(wrapper.vm.nameRegexState).toBe(null); + return wrapper.vm.$nextTick().then(() => { + expect(findSaveButton().attributes('disabled')).toBeFalsy(); + }); + }); + + it(`if the user typed and is less than ${NAME_REGEX_LENGTH} state is true`, () => { + store.dispatch('updateSettings', { name_regex: 'abc' }); + expect(wrapper.vm.nameRegexState).toBe(true); + }); + }); + + describe('help text', () => { + it('toggleDescriptionText text reflects enabled property', () => { + const toggleHelpText = findFormGroup('toggle').find('span'); + expect(toggleHelpText.html()).toContain('disabled'); + wrapper.vm.enabled = true; + return wrapper.vm.$nextTick().then(() => { + expect(toggleHelpText.html()).toContain('enabled'); + }); + }); + }); +}); diff --git a/spec/frontend/registry/settings/mock_data.js b/spec/frontend/registry/settings/mock_data.js new file mode 100644 index 0000000000000000000000000000000000000000..411363c2c95e4acdfdf96e7aada9cd82de9fc93c --- /dev/null +++ b/spec/frontend/registry/settings/mock_data.js @@ -0,0 +1,12 @@ +export const options = [{ key: 'foo', label: 'Foo' }, { key: 'bar', label: 'Bar', default: true }]; +export const stringifiedOptions = JSON.stringify(options); +export const stringifiedFormOptions = { + cadenceOptions: stringifiedOptions, + keepNOptions: stringifiedOptions, + olderThanOptions: stringifiedOptions, +}; +export const formOptions = { + cadence: options, + keepN: options, + olderThan: options, +}; diff --git a/spec/frontend/registry/settings/store/actions_spec.js b/spec/frontend/registry/settings/store/actions_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..80fb800ac3ad7326600821b925f98333ce569772 --- /dev/null +++ b/spec/frontend/registry/settings/store/actions_spec.js @@ -0,0 +1,124 @@ +import Api from '~/api'; +import createFlash from '~/flash'; +import testAction from 'helpers/vuex_action_helper'; +import * as actions from '~/registry/settings/store/actions'; +import * as types from '~/registry/settings/store/mutation_types'; +import { + UPDATE_SETTINGS_ERROR_MESSAGE, + FETCH_SETTINGS_ERROR_MESSAGE, + UPDATE_SETTINGS_SUCCESS_MESSAGE, +} from '~/registry/settings/constants'; + +jest.mock('~/flash'); + +describe('Actions Registry Store', () => { + describe.each` + actionName | mutationName | payload + ${'setInitialState'} | ${types.SET_INITIAL_STATE} | ${'foo'} + ${'updateSettings'} | ${types.UPDATE_SETTINGS} | ${'foo'} + ${'receiveSettingsSuccess'} | ${types.SET_SETTINGS} | ${'foo'} + ${'toggleLoading'} | ${types.TOGGLE_LOADING} | ${undefined} + ${'resetSettings'} | ${types.RESET_SETTINGS} | ${undefined} + `('%s action invokes %s mutation with payload %s', ({ actionName, mutationName, payload }) => { + it('should set the initial state', done => { + testAction(actions[actionName], payload, {}, [{ type: mutationName, payload }], [], done); + }); + }); + + describe.each` + actionName | message + ${'receiveSettingsError'} | ${FETCH_SETTINGS_ERROR_MESSAGE} + ${'updateSettingsError'} | ${UPDATE_SETTINGS_ERROR_MESSAGE} + `('%s action', ({ actionName, message }) => { + it(`should call createFlash with ${message}`, done => { + testAction(actions[actionName], null, null, [], [], () => { + expect(createFlash).toHaveBeenCalledWith(message); + done(); + }); + }); + }); + + describe('fetchSettings', () => { + const state = { + projectId: 'bar', + }; + + const payload = { + data: { + container_expiration_policy: 'foo', + }, + }; + + it('should fetch the data from the API', done => { + Api.project = jest.fn().mockResolvedValue(payload); + testAction( + actions.fetchSettings, + null, + state, + [], + [ + { type: 'toggleLoading' }, + { type: 'receiveSettingsSuccess', payload: payload.data.container_expiration_policy }, + { type: 'toggleLoading' }, + ], + done, + ); + }); + + it('should call receiveSettingsError on error', done => { + Api.project = jest.fn().mockRejectedValue(); + testAction( + actions.fetchSettings, + null, + state, + [], + [{ type: 'toggleLoading' }, { type: 'receiveSettingsError' }, { type: 'toggleLoading' }], + done, + ); + }); + }); + + describe('saveSettings', () => { + const state = { + projectId: 'bar', + settings: 'baz', + }; + + const payload = { + data: { + tag_expiration_policies: 'foo', + }, + }; + + it('should fetch the data from the API', done => { + Api.updateProject = jest.fn().mockResolvedValue(payload); + testAction( + actions.saveSettings, + null, + state, + [], + [ + { type: 'toggleLoading' }, + { type: 'receiveSettingsSuccess', payload: payload.data.container_expiration_policy }, + { type: 'toggleLoading' }, + ], + () => { + expect(createFlash).toHaveBeenCalledWith(UPDATE_SETTINGS_SUCCESS_MESSAGE, 'success'); + done(); + }, + ); + }); + + it('should call receiveSettingsError on error', done => { + Api.updateProject = jest.fn().mockRejectedValue(); + testAction( + actions.saveSettings, + null, + state, + [], + [{ type: 'toggleLoading' }, { type: 'updateSettingsError' }, { type: 'toggleLoading' }], + done, + ); + }); + }); +}); diff --git a/spec/frontend/registry/settings/store/mutations_spec.js b/spec/frontend/registry/settings/store/mutations_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..1a0effbe125e79a8176c647dfb2715b388ef6439 --- /dev/null +++ b/spec/frontend/registry/settings/store/mutations_spec.js @@ -0,0 +1,58 @@ +import mutations from '~/registry/settings/store/mutations'; +import * as types from '~/registry/settings/store/mutation_types'; +import createState from '~/registry/settings/store/state'; +import { formOptions, stringifiedFormOptions } from '../mock_data'; + +describe('Mutations Registry Store', () => { + let mockState; + + beforeEach(() => { + mockState = createState(); + }); + + describe('SET_INITIAL_STATE', () => { + it('should set the initial state', () => { + const expectedState = { ...mockState, projectId: 'foo', formOptions }; + mutations[types.SET_INITIAL_STATE](mockState, { + projectId: 'foo', + ...stringifiedFormOptions, + }); + + expect(mockState.projectId).toEqual(expectedState.projectId); + expect(mockState.formOptions).toEqual(expectedState.formOptions); + }); + }); + + describe('UPDATE_SETTINGS', () => { + it('should update the settings', () => { + mockState.settings = { foo: 'bar' }; + const payload = { foo: 'baz' }; + const expectedState = { ...mockState, settings: payload }; + mutations[types.UPDATE_SETTINGS](mockState, payload); + expect(mockState.settings).toEqual(expectedState.settings); + }); + }); + describe('SET_SETTINGS', () => { + it('should set the settings and original', () => { + const payload = { foo: 'baz' }; + const expectedState = { ...mockState, settings: payload }; + mutations[types.SET_SETTINGS](mockState, payload); + expect(mockState.settings).toEqual(expectedState.settings); + expect(mockState.original).toEqual(expectedState.settings); + }); + }); + describe('RESET_SETTINGS', () => { + it('should copy original over settings', () => { + mockState.settings = { foo: 'bar' }; + mockState.original = { foo: 'baz' }; + mutations[types.RESET_SETTINGS](mockState); + expect(mockState.settings).toEqual(mockState.original); + }); + }); + describe('TOGGLE_LOADING', () => { + it('should toggle the loading', () => { + mutations[types.TOGGLE_LOADING](mockState); + expect(mockState.isLoading).toEqual(true); + }); + }); +}); diff --git a/spec/frontend/registry/settings/stores/actions_spec.js b/spec/frontend/registry/settings/stores/actions_spec.js deleted file mode 100644 index 484f1b2dc0aef7c15c85f38b90078ec3732eb3d6..0000000000000000000000000000000000000000 --- a/spec/frontend/registry/settings/stores/actions_spec.js +++ /dev/null @@ -1,20 +0,0 @@ -import testAction from 'helpers/vuex_action_helper'; -import * as actions from '~/registry/settings/stores/actions'; -import * as types from '~/registry/settings/stores/mutation_types'; - -jest.mock('~/flash.js'); - -describe('Actions Registry Store', () => { - describe('setInitialState', () => { - it('should set the initial state', done => { - testAction( - actions.setInitialState, - 'foo', - {}, - [{ type: types.SET_INITIAL_STATE, payload: 'foo' }], - [], - done, - ); - }); - }); -}); diff --git a/spec/frontend/registry/settings/stores/mutations_spec.js b/spec/frontend/registry/settings/stores/mutations_spec.js deleted file mode 100644 index 421cd3f13cbc495e53a77646a28820bbc460fe7e..0000000000000000000000000000000000000000 --- a/spec/frontend/registry/settings/stores/mutations_spec.js +++ /dev/null @@ -1,21 +0,0 @@ -import mutations from '~/registry/settings/stores/mutations'; -import * as types from '~/registry/settings/stores/mutation_types'; -import createState from '~/registry/settings/stores/state'; - -describe('Mutations Registry Store', () => { - let mockState; - - beforeEach(() => { - mockState = createState(); - }); - - describe('SET_INITIAL_STATE', () => { - it('should set the initial state', () => { - const payload = { helpPagePath: 'foo', registrySettingsEndpoint: 'bar' }; - const expectedState = { ...mockState, ...payload }; - mutations[types.SET_INITIAL_STATE](mockState, payload); - - expect(mockState.endpoint).toEqual(expectedState.endpoint); - }); - }); -}); diff --git a/spec/frontend/releases/detail/components/app_spec.js b/spec/frontend/releases/detail/components/app_spec.js index 4f094e8639a999ada6cecc1151090e8009666c78..fd5239ad44e6788dd3174a4fffbfac32c860b185 100644 --- a/spec/frontend/releases/detail/components/app_spec.js +++ b/spec/frontend/releases/detail/components/app_spec.js @@ -29,7 +29,9 @@ describe('Release detail component', () => { const store = new Vuex.Store({ actions, state }); - wrapper = mount(ReleaseDetailApp, { store, sync: false, attachToDocument: true }); + wrapper = mount(ReleaseDetailApp, { + store, + }); return wrapper.vm.$nextTick(); }); diff --git a/spec/frontend/releases/list/components/evidence_block_spec.js b/spec/frontend/releases/list/components/evidence_block_spec.js index e8a3eace216de572d771d18697e55ca664ee9b70..39f3975f66516bd0cc2d128608e247de02fd51e3 100644 --- a/spec/frontend/releases/list/components/evidence_block_spec.js +++ b/spec/frontend/releases/list/components/evidence_block_spec.js @@ -1,4 +1,4 @@ -import { mount, createLocalVue } from '@vue/test-utils'; +import { mount } from '@vue/test-utils'; import { GlLink } from '@gitlab/ui'; import { truncateSha } from '~/lib/utils/text_utility'; import Icon from '~/vue_shared/components/icon.vue'; @@ -10,10 +10,7 @@ describe('Evidence Block', () => { let wrapper; const factory = (options = {}) => { - const localVue = createLocalVue(); - - wrapper = mount(localVue.extend(EvidenceBlock), { - localVue, + wrapper = mount(EvidenceBlock, { ...options, }); }; @@ -39,7 +36,7 @@ describe('Evidence Block', () => { }); it('renders the correct hover text for the download', () => { - expect(wrapper.find(GlLink).attributes('data-original-title')).toBe('Download evidence JSON'); + expect(wrapper.find(GlLink).attributes('title')).toBe('Download evidence JSON'); }); it('renders the correct file link for download', () => { @@ -53,7 +50,10 @@ describe('Evidence Block', () => { it('renders the long sha after expansion', () => { wrapper.find('.js-text-expander-prepend').trigger('click'); - expect(wrapper.find('.js-expanded').text()).toBe(release.evidence_sha); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.find('.js-expanded').text()).toBe(release.evidence_sha); + }); }); }); @@ -63,9 +63,7 @@ describe('Evidence Block', () => { }); it('renders the correct hover text', () => { - expect(wrapper.find(ClipboardButton).attributes('data-original-title')).toBe( - 'Copy commit SHA', - ); + expect(wrapper.find(ClipboardButton).attributes('title')).toBe('Copy commit SHA'); }); it('copies the sha', () => { diff --git a/spec/frontend/releases/list/components/release_block_footer_spec.js b/spec/frontend/releases/list/components/release_block_footer_spec.js index 7652acbdd62d5bef5f4dbe95cc6b65cf2cb87ee5..07f61303e3376986ce60ee97a98285e42b72991d 100644 --- a/spec/frontend/releases/list/components/release_block_footer_spec.js +++ b/spec/frontend/releases/list/components/release_block_footer_spec.js @@ -27,7 +27,6 @@ describe('Release block footer', () => { ...convertObjectPropsToCamelCase(releaseClone), ...props, }, - sync: false, }); return wrapper.vm.$nextTick(); diff --git a/spec/frontend/releases/list/components/release_block_milestone_info_spec.js b/spec/frontend/releases/list/components/release_block_milestone_info_spec.js index 7179ab3d3cc8960eef78be6afb9ea48d10f9cb58..8a63dbbdca72b5b46a0ecb1f5f766812c49d10fa 100644 --- a/spec/frontend/releases/list/components/release_block_milestone_info_spec.js +++ b/spec/frontend/releases/list/components/release_block_milestone_info_spec.js @@ -14,7 +14,6 @@ describe('Release block milestone info', () => { propsData: { milestones: milestonesProp, }, - sync: false, }); return wrapper.vm.$nextTick(); @@ -61,7 +60,7 @@ describe('Release block milestone info', () => { expect(milestoneLink.text()).toBe(m.title); expect(milestoneLink.attributes('href')).toBe(m.web_url); - expect(milestoneLink.attributes('data-original-title')).toBe(m.description); + expect(milestoneLink.attributes('title')).toBe(m.description); }); }); diff --git a/spec/frontend/releases/list/components/release_block_spec.js b/spec/frontend/releases/list/components/release_block_spec.js index 38c5e4fc0a289c8051b0b6d043b823a868e430e3..20c25a4aac2abbe30f25ab49da65c2f9d8ee96c7 100644 --- a/spec/frontend/releases/list/components/release_block_spec.js +++ b/spec/frontend/releases/list/components/release_block_spec.js @@ -34,7 +34,6 @@ describe('Release block', () => { ...featureFlags, }, }, - sync: false, }); return wrapper.vm.$nextTick(); @@ -170,7 +169,7 @@ describe('Release block', () => { releaseClone.tag_name = 'a dangerous tag name <script>alert("hello")</script>'; return factory(releaseClone).then(() => { - expect(wrapper.attributes().id).toBe('a-dangerous-tag-name-script-alert-hello-script-'); + expect(wrapper.attributes().id).toBe('a-dangerous-tag-name-script-alert-hello-script'); }); }); @@ -271,7 +270,7 @@ describe('Release block', () => { expect(milestoneLink.attributes('href')).toBe(milestone.web_url); - expect(milestoneLink.attributes('data-original-title')).toBe(milestone.description); + expect(milestoneLink.attributes('title')).toBe(milestone.description); }); }); diff --git a/spec/frontend/reports/components/report_item_spec.js b/spec/frontend/reports/components/report_item_spec.js index bacbb3995138303a81d38833e06111637d858309..6aac07984e3bd1c43d78c35d229a5ba5354f772e 100644 --- a/spec/frontend/reports/components/report_item_spec.js +++ b/spec/frontend/reports/components/report_item_spec.js @@ -1,6 +1,7 @@ import { shallowMount } from '@vue/test-utils'; import { STATUS_SUCCESS } from '~/reports/constants'; import ReportItem from '~/reports/components/report_item.vue'; +import IssueStatusIcon from '~/reports/components/issue_status_icon.vue'; import { componentNames } from '~/reports/components/issue_body'; describe('ReportItem', () => { @@ -15,7 +16,7 @@ describe('ReportItem', () => { }, }); - expect(wrapper.find('issuestatusicon-stub').exists()).toBe(false); + expect(wrapper.find(IssueStatusIcon).exists()).toBe(false); }); it('shows status icon when unspecified', () => { @@ -27,7 +28,7 @@ describe('ReportItem', () => { }, }); - expect(wrapper.find('issuestatusicon-stub').exists()).toBe(true); + expect(wrapper.find(IssueStatusIcon).exists()).toBe(true); }); }); }); diff --git a/spec/frontend/repository/components/__snapshots__/directory_download_links_spec.js.snap b/spec/frontend/repository/components/__snapshots__/directory_download_links_spec.js.snap index 31a1cd230604c8bcd379f7b8e9c4b84d4c0793e7..6968fb3e15397c62abcd8adee62bfbb62d046355 100644 --- a/spec/frontend/repository/components/__snapshots__/directory_download_links_spec.js.snap +++ b/spec/frontend/repository/components/__snapshots__/directory_download_links_spec.js.snap @@ -16,22 +16,22 @@ exports[`Repository directory download links component renders downloads links f <div class="btn-group ml-0 w-100" > - <gllink-stub + <gl-link-stub class="btn btn-xs btn-primary" href="http://test.com/?path=app" > zip - </gllink-stub> - <gllink-stub + </gl-link-stub> + <gl-link-stub class="btn btn-xs" href="http://test.com/?path=app" > tar - </gllink-stub> + </gl-link-stub> </div> </div> </section> @@ -53,22 +53,22 @@ exports[`Repository directory download links component renders downloads links f <div class="btn-group ml-0 w-100" > - <gllink-stub + <gl-link-stub class="btn btn-xs btn-primary" href="http://test.com/?path=app/assets" > zip - </gllink-stub> - <gllink-stub + </gl-link-stub> + <gl-link-stub class="btn btn-xs" href="http://test.com/?path=app/assets" > tar - </gllink-stub> + </gl-link-stub> </div> </div> </section> diff --git a/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap b/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap index 706c26403c076056d66ec5735608fa6eb9b6d4e2..1497539a0c164418af3d91e43b8594dfff860aa2 100644 --- a/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap +++ b/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap @@ -4,7 +4,7 @@ exports[`Repository last commit component renders commit widget 1`] = ` <div class="info-well d-none d-sm-flex project-last-commit commit p-3" > - <useravatarlink-stub + <user-avatar-link-stub class="avatar-cell" imgalt="" imgcssclasses="" @@ -22,32 +22,32 @@ exports[`Repository last commit component renders commit widget 1`] = ` <div class="commit-content qa-commit-content" > - <gllink-stub + <gl-link-stub class="commit-row-message item-title" href="https://test.com/commit/123" > Commit title - </gllink-stub> + </gl-link-stub> <!----> <div class="committer" > - <gllink-stub + <gl-link-stub class="commit-author-link js-user-link" href="https://test.com/test" > Test - </gllink-stub> + </gl-link-stub> authored - <timeagotooltip-stub + <timeago-tooltip-stub cssclass="" time="2019-01-01" tooltipplacement="bottom" @@ -65,19 +65,18 @@ exports[`Repository last commit component renders commit widget 1`] = ` <div class="ci-status-link" > - <gllink-stub + <gl-link-stub class="js-commit-pipeline" - data-original-title="Commit: failed" href="https://test.com/pipeline" - title="" + title="Commit: failed" > - <ciicon-stub + <ci-icon-stub aria-label="Commit: failed" cssclasses="" size="24" status="[object Object]" /> - </gllink-stub> + </gl-link-stub> </div> <div @@ -91,7 +90,7 @@ exports[`Repository last commit component renders commit widget 1`] = ` </div> - <clipboardbutton-stub + <clipboard-button-stub cssclass="btn-default" text="123456789" title="Copy commit SHA" @@ -107,7 +106,7 @@ exports[`Repository last commit component renders the signature HTML as returned <div class="info-well d-none d-sm-flex project-last-commit commit p-3" > - <useravatarlink-stub + <user-avatar-link-stub class="avatar-cell" imgalt="" imgcssclasses="" @@ -125,32 +124,32 @@ exports[`Repository last commit component renders the signature HTML as returned <div class="commit-content qa-commit-content" > - <gllink-stub + <gl-link-stub class="commit-row-message item-title" href="https://test.com/commit/123" > Commit title - </gllink-stub> + </gl-link-stub> <!----> <div class="committer" > - <gllink-stub + <gl-link-stub class="commit-author-link js-user-link" href="https://test.com/test" > Test - </gllink-stub> + </gl-link-stub> authored - <timeagotooltip-stub + <timeago-tooltip-stub cssclass="" time="2019-01-01" tooltipplacement="bottom" @@ -172,19 +171,18 @@ exports[`Repository last commit component renders the signature HTML as returned <div class="ci-status-link" > - <gllink-stub + <gl-link-stub class="js-commit-pipeline" - data-original-title="Commit: failed" href="https://test.com/pipeline" - title="" + title="Commit: failed" > - <ciicon-stub + <ci-icon-stub aria-label="Commit: failed" cssclasses="" size="24" status="[object Object]" /> - </gllink-stub> + </gl-link-stub> </div> <div @@ -198,7 +196,7 @@ exports[`Repository last commit component renders the signature HTML as returned </div> - <clipboardbutton-stub + <clipboard-button-stub cssclass="btn-default" text="123456789" title="Copy commit SHA" diff --git a/spec/frontend/repository/components/breadcrumbs_spec.js b/spec/frontend/repository/components/breadcrumbs_spec.js index 707eae34793c7f6a90de890fd13994b1014bd395..bc2abb3db1a36c2eed03404f77d26c8e29fb62a7 100644 --- a/spec/frontend/repository/components/breadcrumbs_spec.js +++ b/spec/frontend/repository/components/breadcrumbs_spec.js @@ -49,7 +49,9 @@ describe('Repository breadcrumbs component', () => { vm.setData({ userPermissions: { forkProject: false, createMergeRequestIn: false } }); - expect(vm.find(GlDropdown).exists()).toBe(false); + return vm.vm.$nextTick(() => { + expect(vm.find(GlDropdown).exists()).toBe(false); + }); }); it('renders add to tree dropdown when permissions are true', () => { @@ -57,6 +59,8 @@ describe('Repository breadcrumbs component', () => { vm.setData({ userPermissions: { forkProject: true, createMergeRequestIn: true } }); - expect(vm.find(GlDropdown).exists()).toBe(true); + return vm.vm.$nextTick(() => { + expect(vm.find(GlDropdown).exists()).toBe(true); + }); }); }); diff --git a/spec/frontend/repository/components/last_commit_spec.js b/spec/frontend/repository/components/last_commit_spec.js index e07ad4cf46b6eb707111844825be18ae573f4592..d2576ec26b79df5b17f0139ac4d647d275e0d3f3 100644 --- a/spec/frontend/repository/components/last_commit_spec.js +++ b/spec/frontend/repository/components/last_commit_spec.js @@ -6,7 +6,7 @@ import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link let vm; function createCommitData(data = {}) { - return { + const defaultData = { sha: '123456789', title: 'Commit title', message: 'Commit message', @@ -26,8 +26,8 @@ function createCommitData(data = {}) { group: {}, }, }, - ...data, }; + return Object.assign(defaultData, data); } function factory(commit = createCommitData(), loading = false) { @@ -46,6 +46,8 @@ function factory(commit = createCommitData(), loading = false) { vm.vm.$apollo.queries.commit.loading = loading; } +const emptyMessageClass = 'font-italic'; + describe('Repository last commit component', () => { afterEach(() => { vm.destroy(); @@ -58,59 +60,89 @@ describe('Repository last commit component', () => { `('$label when loading icon $loading is true', ({ loading }) => { factory(createCommitData(), loading); - expect(vm.find(GlLoadingIcon).exists()).toBe(loading); + return vm.vm.$nextTick(() => { + expect(vm.find(GlLoadingIcon).exists()).toBe(loading); + }); }); it('renders commit widget', () => { factory(); - expect(vm.element).toMatchSnapshot(); + return vm.vm.$nextTick(() => { + expect(vm.element).toMatchSnapshot(); + }); }); it('renders short commit ID', () => { factory(); - expect(vm.find('.label-monospace').text()).toEqual('12345678'); + return vm.vm.$nextTick(() => { + expect(vm.find('.label-monospace').text()).toEqual('12345678'); + }); }); it('hides pipeline components when pipeline does not exist', () => { factory(createCommitData({ pipeline: null })); - expect(vm.find('.js-commit-pipeline').exists()).toBe(false); + return vm.vm.$nextTick(() => { + expect(vm.find('.js-commit-pipeline').exists()).toBe(false); + }); }); it('renders pipeline components', () => { factory(); - expect(vm.find('.js-commit-pipeline').exists()).toBe(true); + return vm.vm.$nextTick(() => { + expect(vm.find('.js-commit-pipeline').exists()).toBe(true); + }); }); it('hides author component when author does not exist', () => { factory(createCommitData({ author: null })); - expect(vm.find('.js-user-link').exists()).toBe(false); - expect(vm.find(UserAvatarLink).exists()).toBe(false); + return vm.vm.$nextTick(() => { + expect(vm.find('.js-user-link').exists()).toBe(false); + expect(vm.find(UserAvatarLink).exists()).toBe(false); + }); }); it('does not render description expander when description is null', () => { factory(createCommitData({ description: null })); - expect(vm.find('.text-expander').exists()).toBe(false); - expect(vm.find('.commit-row-description').exists()).toBe(false); + return vm.vm.$nextTick(() => { + expect(vm.find('.text-expander').exists()).toBe(false); + expect(vm.find('.commit-row-description').exists()).toBe(false); + }); }); it('expands commit description when clicking expander', () => { factory(createCommitData({ description: 'Test description' })); - vm.find('.text-expander').vm.$emit('click'); - - expect(vm.find('.commit-row-description').isVisible()).toBe(true); - expect(vm.find('.text-expander').classes('open')).toBe(true); + return vm.vm + .$nextTick() + .then(() => { + vm.find('.text-expander').vm.$emit('click'); + return vm.vm.$nextTick(); + }) + .then(() => { + expect(vm.find('.commit-row-description').isVisible()).toBe(true); + expect(vm.find('.text-expander').classes('open')).toBe(true); + }); }); it('renders the signature HTML as returned by the backend', () => { factory(createCommitData({ signatureHtml: '<button>Verified</button>' })); - expect(vm.element).toMatchSnapshot(); + return vm.vm.$nextTick().then(() => { + expect(vm.element).toMatchSnapshot(); + }); + }); + + it('sets correct CSS class if the commit message is empty', () => { + factory(createCommitData({ message: '' })); + + return vm.vm.$nextTick().then(() => { + expect(vm.find('.item-title').classes()).toContain(emptyMessageClass); + }); }); }); diff --git a/spec/frontend/repository/components/preview/__snapshots__/index_spec.js.snap b/spec/frontend/repository/components/preview/__snapshots__/index_spec.js.snap index cdc7ece89f6bc8ed2e03e4258b24f20f9cff083c..8eeae9b8455d2cd427567c4a1486f72d4b71dcd9 100644 --- a/spec/frontend/repository/components/preview/__snapshots__/index_spec.js.snap +++ b/spec/frontend/repository/components/preview/__snapshots__/index_spec.js.snap @@ -15,13 +15,13 @@ exports[`Repository file preview component renders file HTML 1`] = ` class="fa fa-file-text-o fa-fw" /> - <gllink-stub + <gl-link-stub href="http://test.com" > <strong> README.md </strong> - </gllink-stub> + </gl-link-stub> </div> </div> diff --git a/spec/frontend/repository/components/preview/index_spec.js b/spec/frontend/repository/components/preview/index_spec.js index 0112e6310f44c1fd0ae03c725744922572b89a60..7587ca4186ce7028d6f3be92e6744ef1403bbe7f 100644 --- a/spec/frontend/repository/components/preview/index_spec.js +++ b/spec/frontend/repository/components/preview/index_spec.js @@ -33,7 +33,9 @@ describe('Repository file preview component', () => { vm.setData({ readme: { html: '<div class="blob">test</div>' } }); - expect(vm.element).toMatchSnapshot(); + return vm.vm.$nextTick(() => { + expect(vm.element).toMatchSnapshot(); + }); }); it('renders loading icon', () => { @@ -44,6 +46,8 @@ describe('Repository file preview component', () => { vm.setData({ loading: 1 }); - expect(vm.find(GlLoadingIcon).exists()).toBe(true); + return vm.vm.$nextTick(() => { + expect(vm.find(GlLoadingIcon).exists()).toBe(true); + }); }); }); diff --git a/spec/frontend/repository/components/table/__snapshots__/row_spec.js.snap b/spec/frontend/repository/components/table/__snapshots__/row_spec.js.snap index f8e65a51297c148add4fcc8cc7746a80d0600d71..22e353dddc5ea1e40d3f654b7cee7d2e906a2c3c 100644 --- a/spec/frontend/repository/components/table/__snapshots__/row_spec.js.snap +++ b/spec/frontend/repository/components/table/__snapshots__/row_spec.js.snap @@ -32,7 +32,7 @@ exports[`Repository table row component renders table row 1`] = ` <td class="d-none d-sm-table-cell tree-commit" > - <glskeletonloading-stub + <gl-skeleton-loading-stub class="h-auto" lines="1" /> @@ -41,7 +41,7 @@ exports[`Repository table row component renders table row 1`] = ` <td class="tree-time-ago text-right" > - <glskeletonloading-stub + <gl-skeleton-loading-stub class="ml-auto h-auto w-50" lines="1" /> diff --git a/spec/frontend/repository/components/table/index_spec.js b/spec/frontend/repository/components/table/index_spec.js index 41450becabb46e40c569209e56854b56cc2235a6..9db90839b29c1c869f361de2a166983e61043c30 100644 --- a/spec/frontend/repository/components/table/index_spec.js +++ b/spec/frontend/repository/components/table/index_spec.js @@ -53,9 +53,11 @@ describe('Repository table component', () => { vm.setData({ ref }); - expect(vm.find('.table').attributes('aria-label')).toEqual( - `Files, directories, and submodules in the path ${path} for commit reference ${ref}`, - ); + return vm.vm.$nextTick(() => { + expect(vm.find('.table').attributes('aria-label')).toEqual( + `Files, directories, and submodules in the path ${path} for commit reference ${ref}`, + ); + }); }); it('shows loading icon', () => { diff --git a/spec/frontend/repository/components/table/parent_row_spec.js b/spec/frontend/repository/components/table/parent_row_spec.js index 7020055271fb9abf0c64d225d2d4c627e6694d4e..439c7ff080cae5a2003b279ce0a1c7e7466c3d58 100644 --- a/spec/frontend/repository/components/table/parent_row_spec.js +++ b/spec/frontend/repository/components/table/parent_row_spec.js @@ -1,10 +1,11 @@ import { shallowMount, RouterLinkStub } from '@vue/test-utils'; +import { GlLoadingIcon } from '@gitlab/ui'; import ParentRow from '~/repository/components/table/parent_row.vue'; let vm; let $router; -function factory(path) { +function factory(path, loadingPath) { $router = { push: jest.fn(), }; @@ -13,6 +14,7 @@ function factory(path) { propsData: { commitRef: 'master', path, + loadingPath, }, stubs: { RouterLink: RouterLinkStub, @@ -61,4 +63,10 @@ describe('Repository parent row component', () => { path: '/tree/master/app', }); }); + + it('renders loading icon when loading parent', () => { + factory('app/assets', 'app'); + + expect(vm.find(GlLoadingIcon).exists()).toBe(true); + }); }); diff --git a/spec/frontend/repository/components/table/row_spec.js b/spec/frontend/repository/components/table/row_spec.js index 94fa8b1e36357ff079b838ddf6ff88f004fc2409..b60560366a667460576884b3dc053d2f5cded302 100644 --- a/spec/frontend/repository/components/table/row_spec.js +++ b/spec/frontend/repository/components/table/row_spec.js @@ -1,5 +1,5 @@ import { shallowMount, RouterLinkStub } from '@vue/test-utils'; -import { GlBadge, GlLink } from '@gitlab/ui'; +import { GlBadge, GlLink, GlLoadingIcon } from '@gitlab/ui'; import { visitUrl } from '~/lib/utils/url_utility'; import TableRow from '~/repository/components/table/row.vue'; import Icon from '~/vue_shared/components/icon.vue'; @@ -46,7 +46,9 @@ describe('Repository table row component', () => { currentPath: '/', }); - expect(vm.element).toMatchSnapshot(); + return vm.vm.$nextTick().then(() => { + expect(vm.element).toMatchSnapshot(); + }); }); it.each` @@ -63,7 +65,9 @@ describe('Repository table row component', () => { currentPath: '/', }); - expect(vm.find(component).exists()).toBe(true); + return vm.vm.$nextTick().then(() => { + expect(vm.find(component).exists()).toBe(true); + }); }); it.each` @@ -80,13 +84,15 @@ describe('Repository table row component', () => { currentPath: '/', }); - vm.trigger('click'); + return vm.vm.$nextTick().then(() => { + vm.trigger('click'); - if (pushes) { - expect($router.push).toHaveBeenCalledWith({ path: '/tree/master/test' }); - } else { - expect($router.push).not.toHaveBeenCalled(); - } + if (pushes) { + expect($router.push).toHaveBeenCalledWith({ path: '/tree/master/test' }); + } else { + expect($router.push).not.toHaveBeenCalled(); + } + }); }); it.each` @@ -103,13 +109,17 @@ describe('Repository table row component', () => { currentPath: '/', }); - vm.trigger('click'); + return vm.vm.$nextTick().then(() => { + vm.trigger('click'); - if (pushes) { - expect(visitUrl).not.toHaveBeenCalled(); - } else { - expect(visitUrl).toHaveBeenCalledWith('https://test.com', undefined); - } + if (pushes) { + expect(visitUrl).not.toHaveBeenCalled(); + } else { + const [url, external] = visitUrl.mock.calls[0]; + expect(url).toBe('https://test.com'); + expect(external).toBeFalsy(); + } + }); }); it('renders commit ID for submodule', () => { @@ -121,7 +131,9 @@ describe('Repository table row component', () => { currentPath: '/', }); - expect(vm.find('.commit-sha').text()).toContain('1'); + return vm.vm.$nextTick().then(() => { + expect(vm.find('.commit-sha').text()).toContain('1'); + }); }); it('renders link with href', () => { @@ -134,7 +146,9 @@ describe('Repository table row component', () => { currentPath: '/', }); - expect(vm.find('a').attributes('href')).toEqual('https://test.com'); + return vm.vm.$nextTick().then(() => { + expect(vm.find('a').attributes('href')).toEqual('https://test.com'); + }); }); it('renders LFS badge', () => { @@ -147,7 +161,9 @@ describe('Repository table row component', () => { lfsOid: '1', }); - expect(vm.find(GlBadge).exists()).toBe(true); + return vm.vm.$nextTick().then(() => { + expect(vm.find(GlBadge).exists()).toBe(true); + }); }); it('renders commit and web links with href for submodule', () => { @@ -161,8 +177,10 @@ describe('Repository table row component', () => { currentPath: '/', }); - expect(vm.find('a').attributes('href')).toEqual('https://test.com'); - expect(vm.find(GlLink).attributes('href')).toEqual('https://test.com/commit'); + return vm.vm.$nextTick().then(() => { + expect(vm.find('a').attributes('href')).toEqual('https://test.com'); + expect(vm.find(GlLink).attributes('href')).toEqual('https://test.com/commit'); + }); }); it('renders lock icon', () => { @@ -176,6 +194,21 @@ describe('Repository table row component', () => { vm.setData({ commit: { lockLabel: 'Locked by Root', committedDate: '2019-01-01' } }); - expect(vm.find(Icon).exists()).toBe(true); + return vm.vm.$nextTick().then(() => { + expect(vm.find(Icon).exists()).toBe(true); + }); + }); + + it('renders loading icon when path is loading', () => { + factory({ + id: '1', + sha: '1', + path: 'test', + type: 'tree', + currentPath: '/', + loadingPath: 'test', + }); + + expect(vm.find(GlLoadingIcon).exists()).toBe(true); }); }); diff --git a/spec/frontend/repository/components/tree_content_spec.js b/spec/frontend/repository/components/tree_content_spec.js index 148e307a5d4b487e9298d218104a5f9bb438f91c..da892ce51d8e9bda05739a049e76cc803d02e04a 100644 --- a/spec/frontend/repository/components/tree_content_spec.js +++ b/spec/frontend/repository/components/tree_content_spec.js @@ -30,7 +30,9 @@ describe('Repository table component', () => { vm.setData({ entries: { blobs: [{ name: 'README.md' }] } }); - expect(vm.find(FilePreview).exists()).toBe(true); + return vm.vm.$nextTick().then(() => { + expect(vm.find(FilePreview).exists()).toBe(true); + }); }); describe('normalizeData', () => { diff --git a/spec/frontend/repository/utils/readme_spec.js b/spec/frontend/repository/utils/readme_spec.js index 6b7876c8947784d451aca2a9da5a21a2b1b176a9..985d947a0af94fe6a9cb1118f6f0536b6f3dbefb 100644 --- a/spec/frontend/repository/utils/readme_spec.js +++ b/spec/frontend/repository/utils/readme_spec.js @@ -1,33 +1,44 @@ import { readmeFile } from '~/repository/utils/readme'; describe('readmeFile', () => { - describe('markdown files', () => { - it('returns markdown file', () => { - expect(readmeFile([{ name: 'README' }, { name: 'README.md' }])).toEqual({ - name: 'README.md', - }); + it('prefers README with markup over plain text README', () => { + expect(readmeFile([{ name: 'README' }, { name: 'README.md' }])).toEqual({ + name: 'README.md', + }); + }); - expect(readmeFile([{ name: 'README' }, { name: 'index.md' }])).toEqual({ - name: 'index.md', - }); + it('is case insensitive', () => { + expect(readmeFile([{ name: 'README' }, { name: 'readme.rdoc' }])).toEqual({ + name: 'readme.rdoc', }); }); - describe('plain files', () => { - it('returns plain file', () => { - expect(readmeFile([{ name: 'README' }, { name: 'TEST.md' }])).toEqual({ - name: 'README', - }); + it('returns the first README found', () => { + expect(readmeFile([{ name: 'INDEX.adoc' }, { name: 'README.md' }])).toEqual({ + name: 'INDEX.adoc', + }); + }); - expect(readmeFile([{ name: 'readme' }, { name: 'TEST.md' }])).toEqual({ - name: 'readme', - }); + it('expects extension to be separated by dot', () => { + expect(readmeFile([{ name: 'readmeXorg' }, { name: 'index.org' }])).toEqual({ + name: 'index.org', }); }); - describe('non-previewable file', () => { - it('returns undefined', () => { - expect(readmeFile([{ name: 'index.js' }, { name: 'TEST.md' }])).toBe(undefined); + it('returns plain text README when there is no README with markup', () => { + expect(readmeFile([{ name: 'README' }, { name: 'NOT_README.md' }])).toEqual({ + name: 'README', }); }); + + it('recognizes Readme.txt as a plain text README', () => { + expect(readmeFile([{ name: 'Readme.txt' }])).toEqual({ + name: 'Readme.txt', + }); + }); + + it('returns undefined when there are no appropriate files', () => { + expect(readmeFile([{ name: 'index.js' }, { name: 'md.README' }])).toBe(undefined); + expect(readmeFile([])).toBe(undefined); + }); }); diff --git a/spec/frontend/self_monitor/components/__snapshots__/self_monitor_spec.js.snap b/spec/frontend/self_monitor/components/__snapshots__/self_monitor_spec.js.snap new file mode 100644 index 0000000000000000000000000000000000000000..1d0f0c024d6681ddebaa77220132f02f0ef5308f --- /dev/null +++ b/spec/frontend/self_monitor/components/__snapshots__/self_monitor_spec.js.snap @@ -0,0 +1,72 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`self monitor component When the self monitor project has not been created default state to match the default snapshot 1`] = ` +<section + class="settings no-animate js-self-monitoring-settings" +> + <div + class="settings-header" + > + <h4 + class="js-section-header" + > + + Self monitoring + + </h4> + + <gl-button-stub + class="js-settings-toggle" + > + Expand + </gl-button-stub> + + <p + class="js-section-sub-header" + > + + Enable or disable instance self monitoring + + </p> + </div> + + <div + class="settings-content" + > + <form + name="self-monitoring-form" + > + <p> + Enabling this feature creates a project that can be used to monitor the health of your instance. + </p> + + <gl-form-group-stub + label="Create Project" + label-for="self-monitor-toggle" + > + <gl-toggle-stub + labeloff="Toggle Status: OFF" + labelon="Toggle Status: ON" + name="self-monitor-toggle" + /> + </gl-form-group-stub> + </form> + </div> + + <gl-modal-stub + cancel-title="Cancel" + modalclass="" + modalid="delete-self-monitor-modal" + ok-title="Delete project" + ok-variant="danger" + title="Disable self monitoring?" + titletag="h4" + > + <div> + + Disabling this feature will delete the self monitoring project. Are you sure you want to delete the project? + + </div> + </gl-modal-stub> +</section> +`; diff --git a/spec/frontend/self_monitor/components/self_monitor_spec.js b/spec/frontend/self_monitor/components/self_monitor_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..b95c75140476ed3eadbb602f86225301dbe448c8 --- /dev/null +++ b/spec/frontend/self_monitor/components/self_monitor_spec.js @@ -0,0 +1,83 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlButton } from '@gitlab/ui'; +import SelfMonitor from '~/self_monitor/components/self_monitor_form.vue'; +import { createStore } from '~/self_monitor/store'; + +describe('self monitor component', () => { + let wrapper; + let store; + + describe('When the self monitor project has not been created', () => { + beforeEach(() => { + store = createStore({ + projectEnabled: false, + selfMonitorProjectCreated: false, + createSelfMonitoringProjectPath: '/create', + deleteSelfMonitoringProjectPath: '/delete', + }); + }); + + afterEach(() => { + if (wrapper.destroy) { + wrapper.destroy(); + } + }); + + describe('default state', () => { + it('to match the default snapshot', () => { + wrapper = shallowMount(SelfMonitor, { store }); + + expect(wrapper.element).toMatchSnapshot(); + }); + }); + + it('renders header text', () => { + wrapper = shallowMount(SelfMonitor, { store }); + + expect(wrapper.find('.js-section-header').text()).toBe('Self monitoring'); + }); + + describe('expand/collapse button', () => { + it('renders as an expand button by default', () => { + wrapper = shallowMount(SelfMonitor, { store }); + + const button = wrapper.find(GlButton); + + expect(button.text()).toBe('Expand'); + }); + }); + + describe('sub-header', () => { + it('renders descriptive text', () => { + wrapper = shallowMount(SelfMonitor, { store }); + + expect(wrapper.find('.js-section-sub-header').text()).toContain( + 'Enable or disable instance self monitoring', + ); + }); + }); + + describe('settings-content', () => { + it('renders the form description without a link', () => { + wrapper = shallowMount(SelfMonitor, { store }); + + expect(wrapper.vm.selfMonitoringFormText).toContain( + 'Enabling this feature creates a project that can be used to monitor the health of your instance.', + ); + }); + + it('renders the form description with a link', () => { + store = createStore({ + projectEnabled: true, + selfMonitorProjectCreated: true, + createSelfMonitoringProjectPath: '/create', + deleteSelfMonitoringProjectPath: '/delete', + }); + + wrapper = shallowMount(SelfMonitor, { store }); + + expect(wrapper.vm.selfMonitoringFormText).toContain('<a href="http://localhost/">'); + }); + }); + }); +}); diff --git a/spec/frontend/self_monitor/store/actions_spec.js b/spec/frontend/self_monitor/store/actions_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..344dbf119543f8638c1389fe1ecebf3c129ed003 --- /dev/null +++ b/spec/frontend/self_monitor/store/actions_spec.js @@ -0,0 +1,255 @@ +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; +import testAction from 'helpers/vuex_action_helper'; +import statusCodes from '~/lib/utils/http_status'; +import * as actions from '~/self_monitor/store/actions'; +import * as types from '~/self_monitor/store/mutation_types'; +import createState from '~/self_monitor/store/state'; + +describe('self monitor actions', () => { + let state; + let mock; + + beforeEach(() => { + state = createState(); + mock = new MockAdapter(axios); + }); + + describe('setSelfMonitor', () => { + it('commits the SET_ENABLED mutation', done => { + testAction( + actions.setSelfMonitor, + null, + state, + [{ type: types.SET_ENABLED, payload: null }], + [], + done, + ); + }); + }); + + describe('resetAlert', () => { + it('commits the SET_ENABLED mutation', done => { + testAction( + actions.resetAlert, + null, + state, + [{ type: types.SET_SHOW_ALERT, payload: false }], + [], + done, + ); + }); + }); + + describe('requestCreateProject', () => { + describe('success', () => { + beforeEach(() => { + state.createProjectEndpoint = '/create'; + state.createProjectStatusEndpoint = '/create_status'; + mock.onPost(state.createProjectEndpoint).reply(statusCodes.ACCEPTED, { + job_id: '123', + }); + mock.onGet(state.createProjectStatusEndpoint).reply(statusCodes.OK, { + project_full_path: '/self-monitor-url', + }); + }); + + it('dispatches status request with job data', done => { + testAction( + actions.requestCreateProject, + null, + state, + [ + { + type: types.SET_LOADING, + payload: true, + }, + ], + [ + { + type: 'requestCreateProjectStatus', + payload: '123', + }, + ], + done, + ); + }); + + it('dispatches success with project path', done => { + testAction( + actions.requestCreateProjectStatus, + null, + state, + [], + [ + { + type: 'requestCreateProjectSuccess', + payload: { project_full_path: '/self-monitor-url' }, + }, + ], + done, + ); + }); + }); + + describe('error', () => { + beforeEach(() => { + state.createProjectEndpoint = '/create'; + mock.onPost(state.createProjectEndpoint).reply(500); + }); + + it('dispatches error', done => { + testAction( + actions.requestCreateProject, + null, + state, + [ + { + type: types.SET_LOADING, + payload: true, + }, + ], + [ + { + type: 'requestCreateProjectError', + payload: new Error('Request failed with status code 500'), + }, + ], + done, + ); + }); + }); + + describe('requestCreateProjectSuccess', () => { + it('should commit the received data', done => { + testAction( + actions.requestCreateProjectSuccess, + { project_full_path: '/self-monitor-url' }, + state, + [ + { type: types.SET_LOADING, payload: false }, + { type: types.SET_PROJECT_URL, payload: '/self-monitor-url' }, + { + type: types.SET_ALERT_CONTENT, + payload: { + actionName: 'viewSelfMonitorProject', + actionText: 'View project', + message: 'Self monitoring project has been successfully created.', + }, + }, + { type: types.SET_SHOW_ALERT, payload: true }, + { type: types.SET_PROJECT_CREATED, payload: true }, + ], + [], + done, + ); + }); + }); + }); + + describe('deleteSelfMonitorProject', () => { + describe('success', () => { + beforeEach(() => { + state.deleteProjectEndpoint = '/delete'; + state.deleteProjectStatusEndpoint = '/delete-status'; + mock.onDelete(state.deleteProjectEndpoint).reply(statusCodes.ACCEPTED, { + job_id: '456', + }); + mock.onGet(state.deleteProjectStatusEndpoint).reply(statusCodes.OK, { + status: 'success', + }); + }); + + it('dispatches status request with job data', done => { + testAction( + actions.requestDeleteProject, + null, + state, + [ + { + type: types.SET_LOADING, + payload: true, + }, + ], + [ + { + type: 'requestDeleteProjectStatus', + payload: '456', + }, + ], + done, + ); + }); + + it('dispatches success with status', done => { + testAction( + actions.requestDeleteProjectStatus, + null, + state, + [], + [ + { + type: 'requestDeleteProjectSuccess', + payload: { status: 'success' }, + }, + ], + done, + ); + }); + }); + + describe('error', () => { + beforeEach(() => { + state.deleteProjectEndpoint = '/delete'; + mock.onDelete(state.deleteProjectEndpoint).reply(500); + }); + + it('dispatches error', done => { + testAction( + actions.requestDeleteProject, + null, + state, + [ + { + type: types.SET_LOADING, + payload: true, + }, + ], + [ + { + type: 'requestDeleteProjectError', + payload: new Error('Request failed with status code 500'), + }, + ], + done, + ); + }); + }); + + describe('requestDeleteProjectSuccess', () => { + it('should commit mutations to remove previously set data', done => { + testAction( + actions.requestDeleteProjectSuccess, + null, + state, + [ + { type: types.SET_PROJECT_URL, payload: '' }, + { type: types.SET_PROJECT_CREATED, payload: false }, + { + type: types.SET_ALERT_CONTENT, + payload: { + actionName: 'createProject', + actionText: 'Undo', + message: 'Self monitoring project has been successfully deleted.', + }, + }, + { type: types.SET_SHOW_ALERT, payload: true }, + { type: types.SET_LOADING, payload: false }, + ], + [], + done, + ); + }); + }); + }); +}); diff --git a/spec/frontend/self_monitor/store/mutations_spec.js b/spec/frontend/self_monitor/store/mutations_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..5282ae3b2f5052d2c9c2774a3e24c6d0818ec2e8 --- /dev/null +++ b/spec/frontend/self_monitor/store/mutations_spec.js @@ -0,0 +1,64 @@ +import mutations from '~/self_monitor/store/mutations'; +import createState from '~/self_monitor/store/state'; + +describe('self monitoring mutations', () => { + let localState; + + beforeEach(() => { + localState = createState(); + }); + + describe('SET_ENABLED', () => { + it('sets selfMonitor', () => { + mutations.SET_ENABLED(localState, true); + + expect(localState.projectEnabled).toBe(true); + }); + }); + + describe('SET_PROJECT_CREATED', () => { + it('sets projectCreated', () => { + mutations.SET_PROJECT_CREATED(localState, true); + + expect(localState.projectCreated).toBe(true); + }); + }); + + describe('SET_SHOW_ALERT', () => { + it('sets showAlert', () => { + mutations.SET_SHOW_ALERT(localState, true); + + expect(localState.showAlert).toBe(true); + }); + }); + + describe('SET_PROJECT_URL', () => { + it('sets projectPath', () => { + mutations.SET_PROJECT_URL(localState, '/url/'); + + expect(localState.projectPath).toBe('/url/'); + }); + }); + + describe('SET_LOADING', () => { + it('sets loading', () => { + mutations.SET_LOADING(localState, true); + + expect(localState.loading).toBe(true); + }); + }); + + describe('SET_ALERT_CONTENT', () => { + it('set alertContent', () => { + const alertContent = { + message: 'success', + actionText: 'undo', + actionName: 'createProject', + }; + + mutations.SET_ALERT_CONTENT(localState, alertContent); + + expect(localState.alertContent).toBe(alertContent); + }); + }); +}); diff --git a/spec/frontend/sentry_error_stack_trace/components/sentry_error_stack_trace_spec.js b/spec/frontend/sentry_error_stack_trace/components/sentry_error_stack_trace_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..e5f83b6fa493bd514e53fdb8b23424937916b1f7 --- /dev/null +++ b/spec/frontend/sentry_error_stack_trace/components/sentry_error_stack_trace_spec.js @@ -0,0 +1,87 @@ +import { createLocalVue, shallowMount } from '@vue/test-utils'; +import Vuex from 'vuex'; +import { GlLoadingIcon } from '@gitlab/ui'; +import Stacktrace from '~/error_tracking/components/stacktrace.vue'; +import SentryErrorStackTrace from '~/sentry_error_stack_trace/components/sentry_error_stack_trace.vue'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('Sentry Error Stack Trace', () => { + let actions; + let getters; + let store; + let wrapper; + + function mountComponent({ + stubs = { + stacktrace: Stacktrace, + }, + } = {}) { + wrapper = shallowMount(SentryErrorStackTrace, { + localVue, + stubs, + store, + propsData: { + issueStackTracePath: '/stacktrace', + }, + }); + } + + beforeEach(() => { + actions = { + startPollingStacktrace: () => {}, + }; + + getters = { + stacktrace: () => [{ context: [1, 2], lineNo: 53, filename: 'index.js' }], + }; + + const state = { + stacktraceData: {}, + loadingStacktrace: true, + }; + + store = new Vuex.Store({ + modules: { + details: { + namespaced: true, + actions, + getters, + state, + }, + }, + }); + }); + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + } + }); + + describe('loading', () => { + it('should show spinner while loading', () => { + mountComponent(); + expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); + expect(wrapper.find(Stacktrace).exists()).toBe(false); + }); + }); + + describe('Stack trace', () => { + beforeEach(() => { + store.state.details.loadingStacktrace = false; + }); + + it('should show stacktrace', () => { + mountComponent({ stubs: {} }); + expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); + expect(wrapper.find(Stacktrace).exists()).toBe(true); + }); + + it('should not show stacktrace if it does not exist', () => { + expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); + expect(wrapper.find(Stacktrace).exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/serverless/components/area_spec.js b/spec/frontend/serverless/components/area_spec.js index 62005e1981ae06885c98aaaacf93cb2bdbaba7da..8b6f664ae257fd3c39b6100c73aef6810b5f1a01 100644 --- a/spec/frontend/serverless/components/area_spec.js +++ b/spec/frontend/serverless/components/area_spec.js @@ -16,7 +16,6 @@ describe('Area component', () => { slots: { default: mockWidgets, }, - sync: false, }); }); diff --git a/spec/frontend/serverless/components/environment_row_spec.js b/spec/frontend/serverless/components/environment_row_spec.js index 866b21659179064514652fa70c0c302222338784..a59b4fdbb7b91e8680d089b0e208884c1bfa37e6 100644 --- a/spec/frontend/serverless/components/environment_row_spec.js +++ b/spec/frontend/serverless/components/environment_row_spec.js @@ -1,20 +1,20 @@ -import { createLocalVue, shallowMount } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; import environmentRowComponent from '~/serverless/components/environment_row.vue'; import { mockServerlessFunctions, mockServerlessFunctionsDiffEnv } from '../mock_data'; import { translate } from '~/serverless/utils'; -const createComponent = (localVue, env, envName) => - shallowMount(environmentRowComponent, { localVue, propsData: { env, envName }, sync: false }).vm; +const createComponent = (env, envName) => + shallowMount(environmentRowComponent, { + propsData: { env, envName }, + }).vm; describe('environment row component', () => { describe('default global cluster case', () => { - let localVue; let vm; beforeEach(() => { - localVue = createLocalVue(); - vm = createComponent(localVue, translate(mockServerlessFunctions.functions)['*'], '*'); + vm = createComponent(translate(mockServerlessFunctions.functions)['*'], '*'); }); afterEach(() => vm.$destroy()); @@ -44,15 +44,9 @@ describe('environment row component', () => { describe('default named cluster case', () => { let vm; - let localVue; beforeEach(() => { - localVue = createLocalVue(); - vm = createComponent( - localVue, - translate(mockServerlessFunctionsDiffEnv.functions).test, - 'test', - ); + vm = createComponent(translate(mockServerlessFunctionsDiffEnv.functions).test, 'test'); }); afterEach(() => vm.$destroy()); diff --git a/spec/frontend/serverless/components/function_details_spec.js b/spec/frontend/serverless/components/function_details_spec.js index 27d3a43db27e3353451c2055a215bff623b02579..40d2bbb0291d4fb78e549693ca009a2fa0c81dab 100644 --- a/spec/frontend/serverless/components/function_details_spec.js +++ b/spec/frontend/serverless/components/function_details_spec.js @@ -41,7 +41,6 @@ describe('functionDetailsComponent', () => { clustersPath: '/clusters', helpPath: '/help', }, - sync: false, }); expect( @@ -69,7 +68,6 @@ describe('functionDetailsComponent', () => { clustersPath: '/clusters', helpPath: '/help', }, - sync: false, }); expect(component.vm.$el.querySelector('p').innerHTML.trim()).toContain('1 pod in use'); @@ -87,7 +85,6 @@ describe('functionDetailsComponent', () => { clustersPath: '/clusters', helpPath: '/help', }, - sync: false, }); expect(component.vm.$el.querySelector('p').innerHTML.trim()).toContain('3 pods in use'); @@ -105,7 +102,6 @@ describe('functionDetailsComponent', () => { clustersPath: '/clusters', helpPath: '/help', }, - sync: false, }); expect( diff --git a/spec/frontend/serverless/components/function_row_spec.js b/spec/frontend/serverless/components/function_row_spec.js index 559c55a1eb4a6406770a07c9b8a1b8ba10140aee..76a9e1493026b1bd522bfa62237b9ba6977d5b46 100644 --- a/spec/frontend/serverless/components/function_row_spec.js +++ b/spec/frontend/serverless/components/function_row_spec.js @@ -8,7 +8,9 @@ describe('functionRowComponent', () => { let wrapper; const createComponent = func => { - wrapper = shallowMount(functionRowComponent, { propsData: { func }, sync: false }); + wrapper = shallowMount(functionRowComponent, { + propsData: { func }, + }); }; afterEach(() => { diff --git a/spec/frontend/serverless/components/functions_spec.js b/spec/frontend/serverless/components/functions_spec.js index 29d35b5f1a623474008a7a8d3becc81db84b03f3..8db0440935775e0f731573e6d6478e66d6583a8b 100644 --- a/spec/frontend/serverless/components/functions_spec.js +++ b/spec/frontend/serverless/components/functions_spec.js @@ -43,7 +43,6 @@ describe('functionsComponent', () => { helpPath: '', statusPath: '', }, - sync: false, }); expect(component.find(EmptyState).exists()).toBe(true); @@ -59,7 +58,6 @@ describe('functionsComponent', () => { helpPath: '', statusPath: '', }, - sync: false, }); expect(component.find(GlLoadingIcon).exists()).toBe(true); @@ -75,7 +73,6 @@ describe('functionsComponent', () => { helpPath: '', statusPath: '', }, - sync: false, }); expect( @@ -102,7 +99,6 @@ describe('functionsComponent', () => { helpPath: '', statusPath: '', }, - sync: false, }); expect(component.find('.js-functions-wrapper').exists()).toBe(true); @@ -118,7 +114,6 @@ describe('functionsComponent', () => { helpPath: 'helpPath', statusPath, }, - sync: false, }); component.vm.$store.dispatch('receiveFunctionsSuccess', mockServerlessFunctions); diff --git a/spec/frontend/serverless/components/missing_prometheus_spec.js b/spec/frontend/serverless/components/missing_prometheus_spec.js index 908f534b84770c7a0de392c4ce7c877c749f62b0..896dc5b43e1feb69a25d8fe44a9281ad0b33501b 100644 --- a/spec/frontend/serverless/components/missing_prometheus_spec.js +++ b/spec/frontend/serverless/components/missing_prometheus_spec.js @@ -9,7 +9,6 @@ const createComponent = missingData => helpPath: '/help', missingData, }, - sync: false, }); describe('missingPrometheusComponent', () => { diff --git a/spec/frontend/serverless/components/pod_box_spec.js b/spec/frontend/serverless/components/pod_box_spec.js index 8563d29c56bada413580cc94ab4b8b458b8d4cc5..495d11bd9ec3e7db2a4450ccd9c33ea19146af0d 100644 --- a/spec/frontend/serverless/components/pod_box_spec.js +++ b/spec/frontend/serverless/components/pod_box_spec.js @@ -6,7 +6,6 @@ const createComponent = count => propsData: { count, }, - sync: false, }).vm; describe('podBoxComponent', () => { diff --git a/spec/frontend/serverless/components/url_spec.js b/spec/frontend/serverless/components/url_spec.js index 9b15df20a898a4b2b5931d91b8059e894ae863e4..36dc9e73c741cc253eaa77739b6ef9ffdb9313e9 100644 --- a/spec/frontend/serverless/components/url_spec.js +++ b/spec/frontend/serverless/components/url_spec.js @@ -8,7 +8,6 @@ const createComponent = uri => propsData: { uri, }, - sync: false, }); describe('urlComponent', () => { diff --git a/spec/javascripts/shared/popover_spec.js b/spec/frontend/shared/popover_spec.js similarity index 74% rename from spec/javascripts/shared/popover_spec.js rename to spec/frontend/shared/popover_spec.js index cc2b2014d38f04282f2b4f003f4f21397d6847e5..bbde936185e12334162a7063d30f9258ebfa7aba 100644 --- a/spec/javascripts/shared/popover_spec.js +++ b/spec/frontend/shared/popover_spec.js @@ -29,7 +29,7 @@ describe('popover', () => { toggleClass: () => {}, }; - spyOn(context, 'popover').and.callFake(method => { + jest.spyOn(context, 'popover').mockImplementation(method => { expect(method).toEqual('show'); done(); }); @@ -44,7 +44,7 @@ describe('popover', () => { toggleClass: () => {}, }; - spyOn(context, 'toggleClass').and.callFake((classNames, show) => { + jest.spyOn(context, 'toggleClass').mockImplementation((classNames, show) => { expect(classNames).toEqual('disable-animation js-popover-show'); expect(show).toEqual(true); done(); @@ -80,7 +80,7 @@ describe('popover', () => { toggleClass: () => {}, }; - spyOn(context, 'popover').and.callFake(method => { + jest.spyOn(context, 'popover').mockImplementation(method => { expect(method).toEqual('hide'); done(); }); @@ -95,7 +95,7 @@ describe('popover', () => { toggleClass: () => {}, }; - spyOn(context, 'toggleClass').and.callFake((classNames, show) => { + jest.spyOn(context, 'toggleClass').mockImplementation((classNames, show) => { expect(classNames).toEqual('disable-animation js-popover-show'); expect(show).toEqual(false); done(); @@ -112,13 +112,13 @@ describe('popover', () => { length: 0, }; - spyOn($.fn, 'init').and.callFake(selector => - selector === '.popover:hover' ? fakeJquery : $.fn, - ); - spyOn(togglePopover, 'call'); + jest + .spyOn($.fn, 'init') + .mockImplementation(selector => (selector === '.popover:hover' ? fakeJquery : $.fn)); + jest.spyOn(togglePopover, 'call').mockImplementation(() => {}); mouseleave(); - expect(togglePopover.call).toHaveBeenCalledWith(jasmine.any(Object), false); + expect(togglePopover.call).toHaveBeenCalledWith(expect.any(Object), false); }); it('does not call hide popover if .popover:hover is true', () => { @@ -126,10 +126,10 @@ describe('popover', () => { length: 1, }; - spyOn($.fn, 'init').and.callFake(selector => - selector === '.popover:hover' ? fakeJquery : $.fn, - ); - spyOn(togglePopover, 'call'); + jest + .spyOn($.fn, 'init') + .mockImplementation(selector => (selector === '.popover:hover' ? fakeJquery : $.fn)); + jest.spyOn(togglePopover, 'call').mockImplementation(() => {}); mouseleave(); expect(togglePopover.call).not.toHaveBeenCalledWith(false); @@ -140,15 +140,15 @@ describe('popover', () => { const context = {}; it('shows popover', () => { - spyOn(togglePopover, 'call').and.returnValue(false); + jest.spyOn(togglePopover, 'call').mockReturnValue(false); mouseenter.call(context); - expect(togglePopover.call).toHaveBeenCalledWith(jasmine.any(Object), true); + expect(togglePopover.call).toHaveBeenCalledWith(expect.any(Object), true); }); it('registers mouseleave event if popover is showed', done => { - spyOn(togglePopover, 'call').and.returnValue(true); - spyOn($.fn, 'on').and.callFake(eventName => { + jest.spyOn(togglePopover, 'call').mockReturnValue(true); + jest.spyOn($.fn, 'on').mockImplementation(eventName => { expect(eventName).toEqual('mouseleave'); done(); }); @@ -156,8 +156,8 @@ describe('popover', () => { }); it('does not register mouseleave event if popover is not showed', () => { - spyOn(togglePopover, 'call').and.returnValue(false); - const spy = spyOn($.fn, 'on').and.callFake(() => {}); + jest.spyOn(togglePopover, 'call').mockReturnValue(false); + const spy = jest.spyOn($.fn, 'on').mockImplementation(() => {}); mouseenter.call(context); expect(spy).not.toHaveBeenCalled(); diff --git a/spec/frontend/sidebar/__snapshots__/todo_spec.js.snap b/spec/frontend/sidebar/__snapshots__/todo_spec.js.snap index 1704206c4ad7d70dbc904180f401bd8af738ee7e..0a12eb327de92b6c4c61b9557a824fd948058695 100644 --- a/spec/frontend/sidebar/__snapshots__/todo_spec.js.snap +++ b/spec/frontend/sidebar/__snapshots__/todo_spec.js.snap @@ -26,7 +26,7 @@ exports[`SidebarTodo template renders component container element with proper da Mark as done </span> - <glloadingicon-stub + <gl-loading-icon-stub color="orange" inline="true" label="Loading" diff --git a/spec/frontend/sidebar/assignees_spec.js b/spec/frontend/sidebar/assignees_spec.js index 14b6da10991b1e6b4e87e9a846766d28b6b47b75..0cb182b2df4fe3db6b836df6d5e687fbe03c46b9 100644 --- a/spec/frontend/sidebar/assignees_spec.js +++ b/spec/frontend/sidebar/assignees_spec.js @@ -15,8 +15,6 @@ describe('Assignee component', () => { const createWrapper = (propsData = getDefaultProps()) => { wrapper = mount(Assignee, { propsData, - sync: false, - attachToDocument: true, }); }; @@ -65,7 +63,9 @@ describe('Assignee component', () => { jest.spyOn(wrapper.vm, '$emit'); wrapper.find('.assign-yourself .btn-link').trigger('click'); - expect(wrapper.emitted('assign-self')).toBeTruthy(); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.emitted('assign-self')).toBeTruthy(); + }); }); }); @@ -178,7 +178,7 @@ describe('Assignee component', () => { const userItems = wrapper.findAll('.user-list .user-item a'); expect(userItems.length).toBe(3); - expect(userItems.at(0).attributes('data-original-title')).toBe(users[2].name); + expect(userItems.at(0).attributes('title')).toBe(users[2].name); }); it('passes the sorted assignees to the collapsed-assignee-list', () => { diff --git a/spec/frontend/sidebar/components/assignees/assignee_avatar_link_spec.js b/spec/frontend/sidebar/components/assignees/assignee_avatar_link_spec.js index 9b2e2e38366bfcb616778dd54f70962256f400ee..03d1ac3ab8d1dcc8522891df7d3a3079fafa30c1 100644 --- a/spec/frontend/sidebar/components/assignees/assignee_avatar_link_spec.js +++ b/spec/frontend/sidebar/components/assignees/assignee_avatar_link_spec.js @@ -23,9 +23,7 @@ describe('AssigneeAvatarLink component', () => { }; wrapper = shallowMount(AssigneeAvatarLink, { - attachToDocument: true, propsData, - sync: false, }); } @@ -33,7 +31,7 @@ describe('AssigneeAvatarLink component', () => { wrapper.destroy(); }); - const findTooltipText = () => wrapper.attributes('data-original-title'); + const findTooltipText = () => wrapper.attributes('title'); it('has the root url present in the assigneeUrl method', () => { createComponent(); diff --git a/spec/frontend/sidebar/components/assignees/assignee_avatar_spec.js b/spec/frontend/sidebar/components/assignees/assignee_avatar_spec.js index e925da0e4c2164d8bda5b7dec27861127b5ffff3..7df37d11987e46c1878e4f13275193c851c584c5 100644 --- a/spec/frontend/sidebar/components/assignees/assignee_avatar_spec.js +++ b/spec/frontend/sidebar/components/assignees/assignee_avatar_spec.js @@ -20,7 +20,6 @@ describe('AssigneeAvatar', () => { wrapper = shallowMount(AssigneeAvatar, { propsData, - sync: false, }); } diff --git a/spec/frontend/sidebar/components/assignees/collapsed_assignee_list_spec.js b/spec/frontend/sidebar/components/assignees/collapsed_assignee_list_spec.js index 6aa7b166804ca417eab797a8c83bdc9421d4e8cd..a1e19c1dd8e7a9fdebe69328d1af421354e4fc51 100644 --- a/spec/frontend/sidebar/components/assignees/collapsed_assignee_list_spec.js +++ b/spec/frontend/sidebar/components/assignees/collapsed_assignee_list_spec.js @@ -16,16 +16,14 @@ describe('CollapsedAssigneeList component', () => { }; wrapper = shallowMount(CollapsedAssigneeList, { - attachToDocument: true, propsData, - sync: false, }); } const findNoUsersIcon = () => wrapper.find('i[aria-label=None]'); const findAvatarCounter = () => wrapper.find('.avatar-counter'); const findAssignees = () => wrapper.findAll(CollapsedAssignee); - const getTooltipTitle = () => wrapper.attributes('data-original-title'); + const getTooltipTitle = () => wrapper.attributes('title'); afterEach(() => { wrapper.destroy(); diff --git a/spec/frontend/sidebar/components/assignees/collapsed_assignee_spec.js b/spec/frontend/sidebar/components/assignees/collapsed_assignee_spec.js index f9ca7bc1ecba2181991984f360e299f169548a34..49a6d9e8ae6f56556ba91b5bc22fd7d20ca475ee 100644 --- a/spec/frontend/sidebar/components/assignees/collapsed_assignee_spec.js +++ b/spec/frontend/sidebar/components/assignees/collapsed_assignee_spec.js @@ -18,7 +18,6 @@ describe('CollapsedAssignee assignee component', () => { wrapper = shallowMount(CollapsedAssignee, { propsData, - sync: false, }); } diff --git a/spec/frontend/sidebar/components/assignees/uncollapsed_assignee_list_spec.js b/spec/frontend/sidebar/components/assignees/uncollapsed_assignee_list_spec.js index 8381cc25db4ba26868c9236a071a708fc603e2f4..1cf0af48bef9b2bf1c9133fd4e0f3b67df7caee6 100644 --- a/spec/frontend/sidebar/components/assignees/uncollapsed_assignee_list_spec.js +++ b/spec/frontend/sidebar/components/assignees/uncollapsed_assignee_list_spec.js @@ -18,8 +18,6 @@ describe('UncollapsedAssigneeList component', () => { }; wrapper = mount(UncollapsedAssigneeList, { - attachToDocument: true, - sync: false, propsData, }); } diff --git a/spec/frontend/sidebar/confidential_issue_sidebar_spec.js b/spec/frontend/sidebar/confidential_issue_sidebar_spec.js index 432ec111e523b38e9c2ad70af6dd43132e204f9e..13b7c426366bf70f7405f31543f5d6aa45277355 100644 --- a/spec/frontend/sidebar/confidential_issue_sidebar_spec.js +++ b/spec/frontend/sidebar/confidential_issue_sidebar_spec.js @@ -37,7 +37,6 @@ describe('Confidential Issue Sidebar Block', () => { service, ...propsData, }, - sync: false, }); }; @@ -78,21 +77,29 @@ describe('Confidential Issue Sidebar Block', () => { it('displays the edit form when editable', () => { wrapper.setData({ edit: false }); - wrapper.find({ ref: 'editLink' }).trigger('click'); - - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.find(EditForm).exists()).toBe(true); - }); + return wrapper.vm + .$nextTick() + .then(() => { + wrapper.find({ ref: 'editLink' }).trigger('click'); + return wrapper.vm.$nextTick(); + }) + .then(() => { + expect(wrapper.find(EditForm).exists()).toBe(true); + }); }); it('displays the edit form when opened from collapsed state', () => { wrapper.setData({ edit: false }); - wrapper.find({ ref: 'collapseIcon' }).trigger('click'); - - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.find(EditForm).exists()).toBe(true); - }); + return wrapper.vm + .$nextTick() + .then(() => { + wrapper.find({ ref: 'collapseIcon' }).trigger('click'); + return wrapper.vm.$nextTick(); + }) + .then(() => { + expect(wrapper.find(EditForm).exists()).toBe(true); + }); }); it('tracks the event when "Edit" is clicked', () => { diff --git a/spec/frontend/sidebar/sidebar_store_spec.js b/spec/frontend/sidebar/sidebar_store_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..6d063a7cfcf9ab12ce7335dbf22fa482fbe9481b --- /dev/null +++ b/spec/frontend/sidebar/sidebar_store_spec.js @@ -0,0 +1,168 @@ +import SidebarStore from '~/sidebar/stores/sidebar_store'; +import Mock from './mock_data'; +import UsersMockHelper from '../helpers/user_mock_data_helper'; + +const ASSIGNEE = { + id: 2, + name: 'gitlab user 2', + username: 'gitlab2', + avatar_url: 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', +}; + +const ANOTHER_ASSINEE = { + id: 3, + name: 'gitlab user 3', + username: 'gitlab3', + avatar_url: 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', +}; + +const PARTICIPANT = { + id: 1, + state: 'active', + username: 'marcene', + name: 'Allie Will', + web_url: 'foo.com', + avatar_url: 'gravatar.com/avatar/xxx', +}; + +const PARTICIPANT_LIST = [PARTICIPANT, { ...PARTICIPANT, id: 2 }, { ...PARTICIPANT, id: 3 }]; + +describe('Sidebar store', () => { + let testContext; + + beforeEach(() => { + testContext = {}; + }); + + beforeEach(() => { + testContext.store = new SidebarStore({ + currentUser: { + id: 1, + name: 'Administrator', + username: 'root', + avatar_url: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + }, + editable: true, + rootPath: '/', + endpoint: '/gitlab-org/gitlab-shell/issues/5.json', + }); + }); + + afterEach(() => { + SidebarStore.singleton = null; + }); + + it('has default isFetching values', () => { + expect(testContext.store.isFetching.assignees).toBe(true); + }); + + it('adds a new assignee', () => { + testContext.store.addAssignee(ASSIGNEE); + + expect(testContext.store.assignees.length).toEqual(1); + }); + + it('removes an assignee', () => { + testContext.store.removeAssignee(ASSIGNEE); + + expect(testContext.store.assignees.length).toEqual(0); + }); + + it('finds an existent assignee', () => { + let foundAssignee; + + testContext.store.addAssignee(ASSIGNEE); + foundAssignee = testContext.store.findAssignee(ASSIGNEE); + + expect(foundAssignee).toBeDefined(); + expect(foundAssignee).toEqual(ASSIGNEE); + foundAssignee = testContext.store.findAssignee(ANOTHER_ASSINEE); + + expect(foundAssignee).toBeUndefined(); + }); + + it('removes all assignees', () => { + testContext.store.removeAllAssignees(); + + expect(testContext.store.assignees.length).toEqual(0); + }); + + it('sets participants data', () => { + expect(testContext.store.participants.length).toEqual(0); + + testContext.store.setParticipantsData({ + participants: PARTICIPANT_LIST, + }); + + expect(testContext.store.isFetching.participants).toEqual(false); + expect(testContext.store.participants.length).toEqual(PARTICIPANT_LIST.length); + }); + + it('sets subcriptions data', () => { + expect(testContext.store.subscribed).toEqual(null); + + testContext.store.setSubscriptionsData({ + subscribed: true, + }); + + expect(testContext.store.isFetching.subscriptions).toEqual(false); + expect(testContext.store.subscribed).toEqual(true); + }); + + it('set assigned data', () => { + const users = { + assignees: UsersMockHelper.createNumberRandomUsers(3), + }; + + testContext.store.setAssigneeData(users); + + expect(testContext.store.isFetching.assignees).toBe(false); + expect(testContext.store.assignees.length).toEqual(3); + }); + + it('sets fetching state', () => { + expect(testContext.store.isFetching.participants).toEqual(true); + + testContext.store.setFetchingState('participants', false); + + expect(testContext.store.isFetching.participants).toEqual(false); + }); + + it('sets loading state', () => { + testContext.store.setLoadingState('assignees', true); + + expect(testContext.store.isLoading.assignees).toEqual(true); + }); + + it('set time tracking data', () => { + testContext.store.setTimeTrackingData(Mock.time); + + expect(testContext.store.timeEstimate).toEqual(Mock.time.time_estimate); + expect(testContext.store.totalTimeSpent).toEqual(Mock.time.total_time_spent); + expect(testContext.store.humanTimeEstimate).toEqual(Mock.time.human_time_estimate); + expect(testContext.store.humanTotalTimeSpent).toEqual(Mock.time.human_total_time_spent); + }); + + it('set autocomplete projects', () => { + const projects = [{ id: 0 }]; + testContext.store.setAutocompleteProjects(projects); + + expect(testContext.store.autocompleteProjects).toEqual(projects); + }); + + it('sets subscribed state', () => { + expect(testContext.store.subscribed).toEqual(null); + + testContext.store.setSubscribedState(true); + + expect(testContext.store.subscribed).toEqual(true); + }); + + it('set move to project ID', () => { + const projectId = 7; + testContext.store.setMoveToProjectId(projectId); + + expect(testContext.store.moveToProjectId).toEqual(projectId); + }); +}); diff --git a/spec/frontend/sidebar/todo_spec.js b/spec/frontend/sidebar/todo_spec.js index 5bbb42d402d8bbfc3bfe16d46d033288618f7f94..18b621cd12dc38cf8333e8c38decaa6f56037c63 100644 --- a/spec/frontend/sidebar/todo_spec.js +++ b/spec/frontend/sidebar/todo_spec.js @@ -14,7 +14,6 @@ describe('SidebarTodo', () => { const createComponent = (props = {}) => { wrapper = shallowMount(SidebarTodos, { - sync: false, propsData: { ...defaultProps, ...props, @@ -60,7 +59,9 @@ describe('SidebarTodo', () => { createComponent(); wrapper.find('button').trigger('click'); - expect(wrapper.emitted().toggleTodo).toBeTruthy(); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.emitted().toggleTodo).toBeTruthy(); + }); }); it('renders component container element with proper data attributes', () => { diff --git a/spec/frontend/snippets/components/app_spec.js b/spec/frontend/snippets/components/app_spec.js index f2800f9e6af5ee4f5c5a747e99f3fef9005096dd..6576e5b075f8a5339378b0f1988c2260a3dfe71a 100644 --- a/spec/frontend/snippets/components/app_spec.js +++ b/spec/frontend/snippets/components/app_spec.js @@ -2,11 +2,10 @@ import SnippetApp from '~/snippets/components/app.vue'; import SnippetHeader from '~/snippets/components/snippet_header.vue'; import { GlLoadingIcon } from '@gitlab/ui'; -import { createLocalVue, shallowMount } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; describe('Snippet view app', () => { let wrapper; - const localVue = createLocalVue(); const defaultProps = { snippetGid: 'gid://gitlab/PersonalSnippet/42', }; @@ -21,9 +20,7 @@ describe('Snippet view app', () => { }; wrapper = shallowMount(SnippetApp, { - sync: false, mocks: { $apollo }, - localVue, propsData: { ...props, }, diff --git a/spec/frontend/snippets/components/snippet_header_spec.js b/spec/frontend/snippets/components/snippet_header_spec.js index 8847a3a693841bbe3bdab0b4de111125e33b8bab..5cf20119189c1b4f8eb7ad37e560a27e5b23d19b 100644 --- a/spec/frontend/snippets/components/snippet_header_spec.js +++ b/spec/frontend/snippets/components/snippet_header_spec.js @@ -2,11 +2,10 @@ import SnippetHeader from '~/snippets/components/snippet_header.vue'; import DeleteSnippetMutation from '~/snippets/mutations/deleteSnippet.mutation.graphql'; import { ApolloMutation } from 'vue-apollo'; import { GlButton, GlModal } from '@gitlab/ui'; -import { createLocalVue, shallowMount } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; describe('Snippet header component', () => { let wrapper; - const localVue = createLocalVue(); const snippet = { snippet: { id: 'gid://gitlab/PersonalSnippet/50', @@ -62,9 +61,7 @@ describe('Snippet header component', () => { }; wrapper = shallowMount(SnippetHeader, { - sync: false, mocks: { $apollo }, - localVue, propsData: { ...defaultProps, }, diff --git a/spec/frontend/snippets/components/snippet_title_spec.js b/spec/frontend/snippets/components/snippet_title_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..a7efa4ae3416e5effe2da96c5e450d577aab1075 --- /dev/null +++ b/spec/frontend/snippets/components/snippet_title_spec.js @@ -0,0 +1,71 @@ +import SnippetTitle from '~/snippets/components/snippet_title.vue'; +import { GlSprintf } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; + +describe('Snippet header component', () => { + let wrapper; + const title = 'The property of Thor'; + const description = 'Do not touch this hammer'; + const snippet = { + snippet: { + title, + description, + }, + }; + + function createComponent({ props = snippet } = {}) { + const defaultProps = Object.assign({}, props); + + wrapper = shallowMount(SnippetTitle, { + propsData: { + ...defaultProps, + }, + }); + } + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders itself', () => { + createComponent(); + expect(wrapper.find('.snippet-header').exists()).toBe(true); + }); + + it('renders snippets title and description', () => { + createComponent(); + expect(wrapper.text().trim()).toContain(title); + expect(wrapper.text().trim()).toContain(description); + }); + + it('does not render recent changes time stamp if there were no updates', () => { + createComponent(); + expect(wrapper.find(GlSprintf).exists()).toBe(false); + }); + + it('does not render recent changes time stamp if the time for creation and updates match', () => { + const props = Object.assign(snippet, { + snippet: { + ...snippet.snippet, + createdAt: '2019-12-16T21:45:36Z', + updatedAt: '2019-12-16T21:45:36Z', + }, + }); + createComponent({ props }); + + expect(wrapper.find(GlSprintf).exists()).toBe(false); + }); + + it('renders translated string with most recent changes timestamp if changes were made', () => { + const props = Object.assign(snippet, { + snippet: { + ...snippet.snippet, + createdAt: '2019-12-16T21:45:36Z', + updatedAt: '2019-15-16T21:45:36Z', + }, + }); + createComponent({ props }); + + expect(wrapper.find(GlSprintf).exists()).toBe(true); + }); +}); diff --git a/spec/javascripts/syntax_highlight_spec.js b/spec/frontend/syntax_highlight_spec.js similarity index 57% rename from spec/javascripts/syntax_highlight_spec.js rename to spec/frontend/syntax_highlight_spec.js index 99c47fa31d495bb505bdfe292279bc7b0e6e284f..d2fb5983f7bf38c7cc6a728444846df5f3c314fd 100644 --- a/spec/javascripts/syntax_highlight_spec.js +++ b/spec/frontend/syntax_highlight_spec.js @@ -3,19 +3,19 @@ import $ from 'jquery'; import syntaxHighlight from '~/syntax_highlight'; -describe('Syntax Highlighter', function() { - const stubUserColorScheme = function(value) { +describe('Syntax Highlighter', () => { + const stubUserColorScheme = value => { if (window.gon == null) { window.gon = {}; } return (window.gon.user_color_scheme = value); }; - describe('on a js-syntax-highlight element', function() { - beforeEach(function() { - return setFixtures('<div class="js-syntax-highlight"></div>'); + describe('on a js-syntax-highlight element', () => { + beforeEach(() => { + setFixtures('<div class="js-syntax-highlight"></div>'); }); - it('applies syntax highlighting', function() { + it('applies syntax highlighting', () => { stubUserColorScheme('monokai'); syntaxHighlight($('.js-syntax-highlight')); @@ -23,14 +23,14 @@ describe('Syntax Highlighter', function() { }); }); - describe('on a parent element', function() { - beforeEach(function() { - return setFixtures( + describe('on a parent element', () => { + beforeEach(() => { + setFixtures( '<div class="parent">\n <div class="js-syntax-highlight"></div>\n <div class="foo"></div>\n <div class="js-syntax-highlight"></div>\n</div>', ); }); - it('applies highlighting to all applicable children', function() { + it('applies highlighting to all applicable children', () => { stubUserColorScheme('monokai'); syntaxHighlight($('.parent')); @@ -38,11 +38,9 @@ describe('Syntax Highlighter', function() { expect($('.monokai').length).toBe(2); }); - it('prevents an infinite loop when no matches exist', function() { + it('prevents an infinite loop when no matches exist', () => { setFixtures('<div></div>'); - const highlight = function() { - return syntaxHighlight($('div')); - }; + const highlight = () => syntaxHighlight($('div')); expect(highlight).not.toThrow(); }); diff --git a/spec/javascripts/task_list_spec.js b/spec/frontend/task_list_spec.js similarity index 78% rename from spec/javascripts/task_list_spec.js rename to spec/frontend/task_list_spec.js index 563f402de581e3d10d417368c56046c1ac2c0aea..1261833e3ece43f4da7a7a7ab8e046b29f87ae12 100644 --- a/spec/javascripts/task_list_spec.js +++ b/spec/frontend/task_list_spec.js @@ -25,10 +25,10 @@ describe('TaskList', () => { }); it('should call init when the class constructed', () => { - spyOn(TaskList.prototype, 'init').and.callThrough(); - spyOn(TaskList.prototype, 'disable'); - spyOn($.prototype, 'taskList'); - spyOn($.prototype, 'on'); + jest.spyOn(TaskList.prototype, 'init'); + jest.spyOn(TaskList.prototype, 'disable').mockImplementation(() => {}); + jest.spyOn($.prototype, 'taskList').mockImplementation(() => {}); + jest.spyOn($.prototype, 'on').mockImplementation(() => {}); taskList = createTaskList(); const $taskListEl = $(taskList.taskListContainerSelector); @@ -59,7 +59,7 @@ describe('TaskList', () => { describe('disableTaskListItems', () => { it('should call taskList method with disable param', () => { - spyOn($.prototype, 'taskList'); + jest.spyOn($.prototype, 'taskList').mockImplementation(() => {}); taskList.disableTaskListItems({ currentTarget }); @@ -69,7 +69,7 @@ describe('TaskList', () => { describe('enableTaskListItems', () => { it('should call taskList method with enable param', () => { - spyOn($.prototype, 'taskList'); + jest.spyOn($.prototype, 'taskList').mockImplementation(() => {}); taskList.enableTaskListItems({ currentTarget }); @@ -79,8 +79,8 @@ describe('TaskList', () => { describe('disable', () => { it('should disable task list items and off document event', () => { - spyOn(taskList, 'disableTaskListItems'); - spyOn($.prototype, 'off'); + jest.spyOn(taskList, 'disableTaskListItems').mockImplementation(() => {}); + jest.spyOn($.prototype, 'off').mockImplementation(() => {}); taskList.disable(); @@ -95,10 +95,10 @@ describe('TaskList', () => { describe('update', () => { it('should disable task list items and make a patch request then enable them again', done => { const response = { data: { lock_version: 3 } }; - spyOn(taskList, 'enableTaskListItems'); - spyOn(taskList, 'disableTaskListItems'); - spyOn(taskList, 'onSuccess'); - spyOn(axios, 'patch').and.returnValue(Promise.resolve(response)); + jest.spyOn(taskList, 'enableTaskListItems').mockImplementation(() => {}); + jest.spyOn(taskList, 'disableTaskListItems').mockImplementation(() => {}); + jest.spyOn(taskList, 'onSuccess').mockImplementation(() => {}); + jest.spyOn(axios, 'patch').mockReturnValue(Promise.resolve(response)); const value = 'hello world'; const endpoint = '/foo'; @@ -139,9 +139,9 @@ describe('TaskList', () => { it('should handle request error and enable task list items', done => { const response = { data: { error: 1 } }; - spyOn(taskList, 'enableTaskListItems'); - spyOn(taskList, 'onError'); - spyOn(axios, 'patch').and.returnValue(Promise.reject({ response })); // eslint-disable-line prefer-promise-reject-errors + jest.spyOn(taskList, 'enableTaskListItems').mockImplementation(() => {}); + jest.spyOn(taskList, 'onError').mockImplementation(() => {}); + jest.spyOn(axios, 'patch').mockReturnValue(Promise.reject({ response })); // eslint-disable-line prefer-promise-reject-errors const event = { detail: {} }; taskList diff --git a/spec/frontend/test_setup.js b/spec/frontend/test_setup.js index ab42dbe7cd1fb796203a627b6d63b2fe1f44e6fa..203781bb6fc034526e561760c7f1265a64d6fc41 100644 --- a/spec/frontend/test_setup.js +++ b/spec/frontend/test_setup.js @@ -57,6 +57,11 @@ Object.assign(global, { // custom-jquery-matchers was written for an old Jest version, we need to make it compatible Object.entries(jqueryMatchers).forEach(([matcherName, matcherFactory]) => { + // Don't override existing Jest matcher + if (matcherName === 'toHaveLength') { + return; + } + expect.extend({ [matcherName]: matcherFactory().compare, }); diff --git a/spec/frontend/version_check_image_spec.js b/spec/frontend/version_check_image_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..2ab157105a1d0ce5e18b8df23bf9046ae7ecdd6f --- /dev/null +++ b/spec/frontend/version_check_image_spec.js @@ -0,0 +1,42 @@ +import $ from 'jquery'; +import VersionCheckImage from '~/version_check_image'; +import ClassSpecHelper from './helpers/class_spec_helper'; + +describe('VersionCheckImage', () => { + let testContext; + + beforeEach(() => { + testContext = {}; + }); + + describe('bindErrorEvent', () => { + ClassSpecHelper.itShouldBeAStaticMethod(VersionCheckImage, 'bindErrorEvent'); + + beforeEach(() => { + testContext.imageElement = $('<div></div>'); + }); + + it('registers an error event', () => { + jest.spyOn($.prototype, 'on').mockImplementation(() => {}); + // eslint-disable-next-line func-names + jest.spyOn($.prototype, 'off').mockImplementation(function() { + return this; + }); + + VersionCheckImage.bindErrorEvent(testContext.imageElement); + + expect($.prototype.off).toHaveBeenCalledWith('error'); + expect($.prototype.on).toHaveBeenCalledWith('error', expect.any(Function)); + }); + + it('hides the imageElement on error', () => { + jest.spyOn($.prototype, 'hide').mockImplementation(() => {}); + + VersionCheckImage.bindErrorEvent(testContext.imageElement); + + testContext.imageElement.trigger('error'); + + expect($.prototype.hide).toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/frontend/vue_mr_widget/components/artifacts_list_app_spec.js b/spec/frontend/vue_mr_widget/components/artifacts_list_app_spec.js index f979d173effc4014f55fb4fd82506e64a0e61c08..1401308f7f0e0aee31be1f399e03022d0ff3e296 100644 --- a/spec/frontend/vue_mr_widget/components/artifacts_list_app_spec.js +++ b/spec/frontend/vue_mr_widget/components/artifacts_list_app_spec.js @@ -29,7 +29,7 @@ describe('Merge Requests Artifacts list app', () => { }); const createComponent = () => { - wrapper = mount(localVue.extend(ArtifactsListApp), { + wrapper = mount(ArtifactsListApp, { propsData: { endpoint: TEST_HOST, }, @@ -38,7 +38,6 @@ describe('Merge Requests Artifacts list app', () => { ...actionSpies, }, localVue, - sync: false, }); }; diff --git a/spec/frontend/vue_mr_widget/components/artifacts_list_spec.js b/spec/frontend/vue_mr_widget/components/artifacts_list_spec.js index 8c805faf57472afb55ac238f968b2780f59dd69b..1b1624e3e8f0fb5ce2cb361cf43f902af749d7b6 100644 --- a/spec/frontend/vue_mr_widget/components/artifacts_list_spec.js +++ b/spec/frontend/vue_mr_widget/components/artifacts_list_spec.js @@ -1,23 +1,20 @@ -import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; import { GlLink } from '@gitlab/ui'; import ArtifactsList from '~/vue_merge_request_widget/components/artifacts_list.vue'; import { artifactsList } from './mock_data'; describe('Artifacts List', () => { let wrapper; - const localVue = createLocalVue(); const data = { artifacts: artifactsList, }; const mountComponent = props => { - wrapper = shallowMount(localVue.extend(ArtifactsList), { + wrapper = shallowMount(ArtifactsList, { propsData: { ...props, }, - sync: false, - localVue, }); }; diff --git a/spec/frontend/vue_mr_widget/components/mr_collapsible_extension_spec.js b/spec/frontend/vue_mr_widget/components/mr_collapsible_extension_spec.js index 5f101ba4cf65477708f85a3de860962201d8d917..a7ecb863cfbcc5fc3a1e01ca182973fd6871ab35 100644 --- a/spec/frontend/vue_mr_widget/components/mr_collapsible_extension_spec.js +++ b/spec/frontend/vue_mr_widget/components/mr_collapsible_extension_spec.js @@ -42,6 +42,7 @@ describe('Merge Request Collapsible Extension', () => { describe('onClick', () => { beforeEach(() => { wrapper.find('button').trigger('click'); + return wrapper.vm.$nextTick(); }); it('rendes the provided slot', () => { diff --git a/spec/frontend/vue_mr_widget/components/mr_widget_container_spec.js b/spec/frontend/vue_mr_widget/components/mr_widget_container_spec.js index 16c8c939a6ffd9f3a17048cc110fd481b3145aa2..60f970e0018f983699564240d27f45040a52bc20 100644 --- a/spec/frontend/vue_mr_widget/components/mr_widget_container_spec.js +++ b/spec/frontend/vue_mr_widget/components/mr_widget_container_spec.js @@ -1,4 +1,4 @@ -import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { shallowMount } 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>'; @@ -8,10 +8,7 @@ describe('MrWidgetContainer', () => { let wrapper; const factory = (options = {}) => { - const localVue = createLocalVue(); - - wrapper = shallowMount(localVue.extend(MrWidgetContainer), { - localVue, + wrapper = shallowMount(MrWidgetContainer, { ...options, }); }; diff --git a/spec/frontend/vue_mr_widget/components/mr_widget_icon_spec.js b/spec/frontend/vue_mr_widget/components/mr_widget_icon_spec.js index f7c2376eebff3178d807b7c81db4c57400817b0a..cee0b9b011819442da55df64db2d0d7cee9a0701 100644 --- a/spec/frontend/vue_mr_widget/components/mr_widget_icon_spec.js +++ b/spec/frontend/vue_mr_widget/components/mr_widget_icon_spec.js @@ -1,4 +1,4 @@ -import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; import MrWidgetIcon from '~/vue_merge_request_widget/components/mr_widget_icon.vue'; import Icon from '~/vue_shared/components/icon.vue'; @@ -8,14 +8,10 @@ describe('MrWidgetIcon', () => { let wrapper; beforeEach(() => { - const localVue = createLocalVue(); - - wrapper = shallowMount(localVue.extend(MrWidgetIcon), { + wrapper = shallowMount(MrWidgetIcon, { propsData: { name: TEST_ICON, }, - sync: false, - localVue, }); }); diff --git a/spec/frontend/vue_mr_widget/components/states/commit_edit_spec.js b/spec/frontend/vue_mr_widget/components/states/commit_edit_spec.js index 994d6255324fcff3c6d56b99d0c34973d3f13e18..5d09af50420fce241052382ab2200f0a7081fecd 100644 --- a/spec/frontend/vue_mr_widget/components/states/commit_edit_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/commit_edit_spec.js @@ -1,7 +1,6 @@ -import { createLocalVue, shallowMount } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; import CommitEdit from '~/vue_merge_request_widget/components/states/commit_edit.vue'; -const localVue = createLocalVue(); const testCommitMessage = 'Test commit message'; const testLabel = 'Test label'; const testInputId = 'test-input-id'; @@ -10,9 +9,7 @@ describe('Commits edit component', () => { let wrapper; const createComponent = (slots = {}) => { - wrapper = shallowMount(localVue.extend(CommitEdit), { - localVue, - sync: false, + wrapper = shallowMount(CommitEdit, { propsData: { value: testCommitMessage, label: testLabel, @@ -55,8 +52,10 @@ describe('Commits edit component', () => { findTextarea().element.value = changedCommitMessage; findTextarea().trigger('input'); - expect(wrapper.emitted().input[0]).toEqual([changedCommitMessage]); - expect(findTextarea().element.value).toBe(changedCommitMessage); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.emitted().input[0]).toEqual([changedCommitMessage]); + expect(findTextarea().element.value).toBe(changedCommitMessage); + }); }); }); diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_auto_merge_failed_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_auto_merge_failed_spec.js index 1f4d1e17ea0bd3c77ce2f8a87cdafa4eb8a8d5fe..98af44b097515669746bec6ce02b91c47e2694be 100644 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_auto_merge_failed_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_auto_merge_failed_spec.js @@ -10,7 +10,6 @@ describe('MRWidgetAutoMergeFailed', () => { const createComponent = (props = {}) => { wrapper = shallowMount(AutoMergeFailedComponent, { - sync: false, propsData: { ...props }, }); }; diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_commit_message_dropdown_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_commit_message_dropdown_spec.js index daf1cc2d98b20457428cce62ddcfc8498180803b..56832f82b05aabe0931c9929c918de1839a53c36 100644 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_commit_message_dropdown_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_commit_message_dropdown_spec.js @@ -1,8 +1,7 @@ -import { createLocalVue, shallowMount } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; import { GlDropdownItem } from '@gitlab/ui'; import CommitMessageDropdown from '~/vue_merge_request_widget/components/states/commit_message_dropdown.vue'; -const localVue = createLocalVue(); const commits = [ { title: 'Commit 1', @@ -25,9 +24,7 @@ describe('Commits message dropdown component', () => { let wrapper; const createComponent = () => { - wrapper = shallowMount(localVue.extend(CommitMessageDropdown), { - localVue, - sync: false, + wrapper = shallowMount(CommitMessageDropdown, { propsData: { commits, }, @@ -56,6 +53,8 @@ describe('Commits message dropdown component', () => { it('should emit a commit title on selecting commit', () => { findFirstDropdownElement().vm.$emit('click'); - expect(wrapper.emitted().input[0]).toEqual(['Update test.txt']); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.emitted().input[0]).toEqual(['Update test.txt']); + }); }); }); diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_commits_header_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_commits_header_spec.js index 9ee2f88c78d4ff0fcef7b0fe4a4341c26564c8a3..67746b062b98cc1d506f4bebeb611fad30689938 100644 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_commits_header_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_commits_header_spec.js @@ -1,16 +1,12 @@ -import { createLocalVue, shallowMount } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; import CommitsHeader from '~/vue_merge_request_widget/components/states/commits_header.vue'; import Icon from '~/vue_shared/components/icon.vue'; -const localVue = createLocalVue(); - describe('Commits header component', () => { let wrapper; const createComponent = props => { - wrapper = shallowMount(localVue.extend(CommitsHeader), { - localVue, - sync: false, + wrapper = shallowMount(CommitsHeader, { propsData: { isSquashEnabled: false, targetBranch: 'master', @@ -64,7 +60,9 @@ describe('Commits header component', () => { createComponent(); wrapper.setData({ expanded: false }); - expect(findIcon().props('name')).toBe('chevron-right'); + return wrapper.vm.$nextTick().then(() => { + expect(findIcon().props('name')).toBe('chevron-right'); + }); }); describe('when squash is disabled', () => { diff --git a/spec/frontend/vue_mr_widget/deployment/deployment_spec.js b/spec/frontend/vue_mr_widget/deployment/deployment_spec.js index 78e086e473d7cd307f28654c9c97f3bc0ae2d54f..2902c8280dd62f60b2982ccbf2150166f1e3d674 100644 --- a/spec/frontend/vue_mr_widget/deployment/deployment_spec.js +++ b/spec/frontend/vue_mr_widget/deployment/deployment_spec.js @@ -134,7 +134,7 @@ describe('Deployment component', () => { if (status === SUCCESS) { expect(wrapper.find(DeploymentViewButton).text()).toContain('View app'); } else { - expect(wrapper.find(DeploymentViewButton).text()).toContain('View previous app'); + expect(wrapper.find(DeploymentViewButton).text()).toContain('View latest app'); } }); } diff --git a/spec/frontend/vue_mr_widget/deployment/deployment_view_button_spec.js b/spec/frontend/vue_mr_widget/deployment/deployment_view_button_spec.js index 6e3c6f64c6855409f21932d6c3b914e86ccc0dfe..5e0f38459b049dac0e45aab246c9b0311b10f9f4 100644 --- a/spec/frontend/vue_mr_widget/deployment/deployment_view_button_spec.js +++ b/spec/frontend/vue_mr_widget/deployment/deployment_view_button_spec.js @@ -1,16 +1,18 @@ -import { mount, createLocalVue } from '@vue/test-utils'; +import { mount } from '@vue/test-utils'; import DeploymentViewButton from '~/vue_merge_request_widget/components/deployment/deployment_view_button.vue'; import ReviewAppLink from '~/vue_merge_request_widget/components/review_app_link.vue'; import deploymentMockData from './deployment_mock_data'; +const appButtonText = { + text: 'View app', + tooltip: 'View the latest successful deployment to this environment', +}; + describe('Deployment View App button', () => { let wrapper; const factory = (options = {}) => { - const localVue = createLocalVue(); - - wrapper = mount(localVue.extend(DeploymentViewButton), { - localVue, + wrapper = mount(DeploymentViewButton, { ...options, }); }; @@ -19,7 +21,7 @@ describe('Deployment View App button', () => { factory({ propsData: { deployment: deploymentMockData, - isCurrent: true, + appButtonText, }, }); }); @@ -29,25 +31,8 @@ describe('Deployment View App button', () => { }); describe('text', () => { - describe('when app is current', () => { - it('shows View app', () => { - expect(wrapper.find(ReviewAppLink).text()).toContain('View app'); - }); - }); - - describe('when app is not current', () => { - beforeEach(() => { - factory({ - propsData: { - deployment: deploymentMockData, - isCurrent: false, - }, - }); - }); - - it('shows View Previous app', () => { - expect(wrapper.find(ReviewAppLink).text()).toContain('View previous app'); - }); + it('renders text as passed', () => { + expect(wrapper.find(ReviewAppLink).text()).toContain(appButtonText.text); }); }); @@ -56,7 +41,7 @@ describe('Deployment View App button', () => { factory({ propsData: { deployment: { ...deploymentMockData, changes: null }, - isCurrent: false, + appButtonText, }, }); }); @@ -71,7 +56,7 @@ describe('Deployment View App button', () => { factory({ propsData: { deployment: { ...deploymentMockData, changes: [deploymentMockData.changes[0]] }, - isCurrent: false, + appButtonText, }, }); }); @@ -94,7 +79,7 @@ describe('Deployment View App button', () => { factory({ propsData: { deployment: deploymentMockData, - isCurrent: false, + appButtonText, }, }); }); diff --git a/spec/frontend/vue_shared/components/__snapshots__/expand_button_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/expand_button_spec.js.snap index cf71aefebe89dafca1127d093af616284fe764cb..3a518029702e0eb040a8c5bb8116b25a1d488553 100644 --- a/spec/frontend/vue_shared/components/__snapshots__/expand_button_spec.js.snap +++ b/spec/frontend/vue_shared/components/__snapshots__/expand_button_spec.js.snap @@ -1,5 +1,14 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Expand button on click when short text is provided renders button after text 1`] = `"<span><button aria-label=\\"Click to expand text\\" type=\\"button\\" class=\\"btn js-text-expander-prepend text-expander btn-blank btn-secondary\\" style=\\"display: none;\\"><svg aria-hidden=\\"true\\" class=\\"s12 ic-ellipsis_h\\"><use xlink:href=\\"#ellipsis_h\\"></use></svg></button> <!----> <span><p>Expanded!</p></span> <button aria-label=\\"Click to expand text\\" type=\\"button\\" class=\\"btn js-text-expander-append text-expander btn-blank btn-secondary\\" style=\\"\\"><svg aria-hidden=\\"true\\" class=\\"s12 ic-ellipsis_h\\"><use xlink:href=\\"#ellipsis_h\\"></use></svg></button></span>"`; +exports[`Expand button on click when short text is provided renders button after text 1`] = ` +"<span><button aria-label=\\"Click to expand text\\" type=\\"button\\" class=\\"btn js-text-expander-prepend text-expander btn-blank btn-secondary\\" style=\\"display: none;\\"><svg aria-hidden=\\"true\\" class=\\"s12 ic-ellipsis_h\\"><use xlink:href=\\"#ellipsis_h\\"></use></svg></button> <!----> <span><p>Expanded!</p></span> <button aria-label=\\"Click to expand text\\" type=\\"button\\" class=\\"btn js-text-expander-append text-expander btn-blank btn-secondary\\" style=\\"\\"><svg aria-hidden=\\"true\\" class=\\"s12 ic-ellipsis_h\\"> + <use xlink:href=\\"#ellipsis_h\\"></use> + </svg></button></span>" +`; -exports[`Expand button when short text is provided renders button before text 1`] = `"<span><button aria-label=\\"Click to expand text\\" type=\\"button\\" class=\\"btn js-text-expander-prepend text-expander btn-blank btn-secondary\\"><svg aria-hidden=\\"true\\" class=\\"s12 ic-ellipsis_h\\"><use xlink:href=\\"#ellipsis_h\\"></use></svg></button> <span><p>Short</p></span> <!----> <button aria-label=\\"Click to expand text\\" type=\\"button\\" class=\\"btn js-text-expander-append text-expander btn-blank btn-secondary\\" style=\\"display: none;\\"><svg aria-hidden=\\"true\\" class=\\"s12 ic-ellipsis_h\\"><use xlink:href=\\"#ellipsis_h\\"></use></svg></button></span>"`; +exports[`Expand button when short text is provided renders button before text 1`] = ` +"<span><button aria-label=\\"Click to expand text\\" type=\\"button\\" class=\\"btn js-text-expander-prepend text-expander btn-blank btn-secondary\\"><svg aria-hidden=\\"true\\" class=\\"s12 ic-ellipsis_h\\"><use xlink:href=\\"#ellipsis_h\\"></use></svg></button> <span><p>Short</p></span> +<!----> <button aria-label=\\"Click to expand text\\" type=\\"button\\" class=\\"btn js-text-expander-append text-expander btn-blank btn-secondary\\" style=\\"display: none;\\"><svg aria-hidden=\\"true\\" class=\\"s12 ic-ellipsis_h\\"> + <use xlink:href=\\"#ellipsis_h\\"></use> + </svg></button></span>" +`; diff --git a/spec/frontend/vue_shared/components/__snapshots__/memory_graph_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/memory_graph_spec.js.snap index a7f666ff56d5c8ace26b3505571188bd0105e8ef..f4f9cc288f959e8666a0de08f87e139df4b559fc 100644 --- a/spec/frontend/vue_shared/components/__snapshots__/memory_graph_spec.js.snap +++ b/spec/frontend/vue_shared/components/__snapshots__/memory_graph_spec.js.snap @@ -5,7 +5,7 @@ exports[`MemoryGraph Render chart should draw container with chart 1`] = ` class="memory-graph-container p-1" style="width: 100px;" > - <glsparklinechart-stub + <gl-sparkline-chart-stub data="Nov 12 2019 19:17:33,2.87,Nov 12 2019 19:18:33,2.78,Nov 12 2019 19:19:33,2.78,Nov 12 2019 19:20:33,3.01" height="25" tooltiplabel="MB" diff --git a/spec/frontend/vue_shared/components/__snapshots__/split_button_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/split_button_spec.js.snap index 530428ef27cde3df0df086ab3a3c6b61de0e40a2..74f71c23d020a71edb399f906a8c83984ae5d1ca 100644 --- a/spec/frontend/vue_shared/components/__snapshots__/split_button_spec.js.snap +++ b/spec/frontend/vue_shared/components/__snapshots__/split_button_spec.js.snap @@ -1,13 +1,13 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`SplitButton renders actionItems 1`] = ` -<gldropdown-stub +<gl-dropdown-stub menu-class="dropdown-menu-selectable " split="true" text="professor" variant="secondary" > - <gldropdownitem-stub + <gl-dropdown-item-stub active="true" active-class="is-active" > @@ -18,10 +18,10 @@ exports[`SplitButton renders actionItems 1`] = ` <div> very symphonic </div> - </gldropdownitem-stub> + </gl-dropdown-item-stub> - <gldropdowndivider-stub /> - <gldropdownitem-stub + <gl-dropdown-divider-stub /> + <gl-dropdown-item-stub active-class="is-active" > <strong> @@ -31,8 +31,8 @@ exports[`SplitButton renders actionItems 1`] = ` <div> warp drive </div> - </gldropdownitem-stub> + </gl-dropdown-item-stub> <!----> -</gldropdown-stub> +</gl-dropdown-stub> `; diff --git a/spec/frontend/vue_shared/components/callout_spec.js b/spec/frontend/vue_shared/components/callout_spec.js index 91208dfb31aa5ac5355aa179b491b4e28a6cb5da..7c9bb6b465080faa37a1c9bbe677ea07cbd9d23a 100644 --- a/spec/frontend/vue_shared/components/callout_spec.js +++ b/spec/frontend/vue_shared/components/callout_spec.js @@ -1,17 +1,14 @@ -import { createLocalVue, shallowMount } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; import Callout from '~/vue_shared/components/callout.vue'; const TEST_MESSAGE = 'This is a callout message!'; const TEST_SLOT = '<button>This is a callout slot!</button>'; -const localVue = createLocalVue(); - describe('Callout Component', () => { let wrapper; const factory = options => { - wrapper = shallowMount(localVue.extend(Callout), { - localVue, + wrapper = shallowMount(Callout, { ...options, }); }; diff --git a/spec/frontend/vue_shared/components/changed_file_icon_spec.js b/spec/frontend/vue_shared/components/changed_file_icon_spec.js index 2fabbe3d0f603d5427880f7c105865cc71b11a7e..02c4dabeffc64694db322a1740097437439f9dc6 100644 --- a/spec/frontend/vue_shared/components/changed_file_icon_spec.js +++ b/spec/frontend/vue_shared/components/changed_file_icon_spec.js @@ -18,8 +18,6 @@ describe('Changed file icon', () => { showTooltip: true, ...props, }, - sync: false, - attachToDocument: true, }); }; @@ -30,7 +28,7 @@ describe('Changed file icon', () => { const findIcon = () => wrapper.find(Icon); const findIconName = () => findIcon().props('name'); const findIconClasses = () => findIcon().classes(); - const findTooltipText = () => wrapper.attributes('data-original-title'); + const findTooltipText = () => wrapper.attributes('title'); it('with isCentered true, adds center class', () => { factory({ @@ -58,10 +56,10 @@ describe('Changed file icon', () => { describe.each` file | iconName | tooltipText | desc - ${changedFile()} | ${'file-modified'} | ${'Unstaged modification'} | ${'with file changed'} + ${changedFile()} | ${'file-modified-solid'} | ${'Unstaged modification'} | ${'with file changed'} ${stagedFile()} | ${'file-modified-solid'} | ${'Staged modification'} | ${'with file staged'} - ${changedAndStagedFile()} | ${'file-modified'} | ${'Unstaged and staged modification'} | ${'with file changed and staged'} - ${newFile()} | ${'file-addition'} | ${'Unstaged addition'} | ${'with file new'} + ${changedAndStagedFile()} | ${'file-modified-solid'} | ${'Unstaged and staged modification'} | ${'with file changed and staged'} + ${newFile()} | ${'file-addition-solid'} | ${'Unstaged addition'} | ${'with file new'} `('$desc', ({ file, iconName, tooltipText }) => { beforeEach(() => { factory({ file }); @@ -89,7 +87,7 @@ describe('Changed file icon', () => { }); it('does not have tooltip text', () => { - expect(findTooltipText()).toBe(''); + expect(findTooltipText()).toBeFalsy(); }); }); diff --git a/spec/frontend/vue_shared/components/clipboard_button_spec.js b/spec/frontend/vue_shared/components/clipboard_button_spec.js index 4fb6924dabad8d6b1f90d11098031c0acc7ab4b8..37f71867ab95e9142e089e70ef1a0429032b932d 100644 --- a/spec/frontend/vue_shared/components/clipboard_button_spec.js +++ b/spec/frontend/vue_shared/components/clipboard_button_spec.js @@ -9,8 +9,6 @@ describe('clipboard button', () => { const createWrapper = propsData => { wrapper = shallowMount(ClipboardButton, { propsData, - sync: false, - attachToDocument: true, }); }; @@ -35,7 +33,7 @@ describe('clipboard button', () => { }); it('should have a tooltip with default values', () => { - expect(wrapper.attributes('data-original-title')).toBe('Copy this value'); + expect(wrapper.attributes('title')).toBe('Copy this value'); }); it('should render provided classname', () => { diff --git a/spec/frontend/vue_shared/components/commit_spec.js b/spec/frontend/vue_shared/components/commit_spec.js index 67262eec0a546bf41487f761cfc97ee71b1430d6..3510c9b699db5822ce52ee2ef3f8d29819594b57 100644 --- a/spec/frontend/vue_shared/components/commit_spec.js +++ b/spec/frontend/vue_shared/components/commit_spec.js @@ -7,13 +7,16 @@ describe('Commit component', () => { let props; let wrapper; + const findIcon = name => { + const icons = wrapper.findAll(Icon).filter(c => c.attributes('name') === name); + return icons.length ? icons.at(0) : icons; + }; + const findUserAvatar = () => wrapper.find(UserAvatarLink); const createComponent = propsData => { wrapper = shallowMount(CommitComponent, { propsData, - sync: false, - attachToDocument: true, }); }; @@ -71,7 +74,7 @@ describe('Commit component', () => { }); it('should render a tag icon if it represents a tag', () => { - expect(wrapper.find('icon-stub[name="tag"]').exists()).toBe(true); + expect(findIcon('tag').exists()).toBe(true); }); it('should render a link to the ref url', () => { @@ -89,7 +92,7 @@ describe('Commit component', () => { }); it('should render icon for commit', () => { - expect(wrapper.find('icon-stub[name="commit"]').exists()).toBe(true); + expect(findIcon('commit').exists()).toBe(true); }); describe('Given commit title and author props', () => { @@ -160,9 +163,9 @@ describe('Commit component', () => { expect(refEl.attributes('href')).toBe(props.commitRef.ref_url); - expect(refEl.attributes('data-original-title')).toBe(props.commitRef.name); + expect(refEl.attributes('title')).toBe(props.commitRef.name); - expect(wrapper.find('icon-stub[name="branch"]').exists()).toBe(true); + expect(findIcon('branch').exists()).toBe(true); }); }); @@ -193,9 +196,9 @@ describe('Commit component', () => { expect(refEl.attributes('href')).toBe(props.mergeRequestRef.path); - expect(refEl.attributes('data-original-title')).toBe(props.mergeRequestRef.title); + expect(refEl.attributes('title')).toBe(props.mergeRequestRef.title); - expect(wrapper.find('icon-stub[name="git-merge"]').exists()).toBe(true); + expect(findIcon('git-merge').exists()).toBe(true); }); }); diff --git a/spec/frontend/vue_shared/components/dropdown/dropdown_search_input_spec.js b/spec/frontend/vue_shared/components/dropdown/dropdown_search_input_spec.js index 0d0e4ae434913d4df14781a0b8f4b6644dd9d755..ffdeb25439c576a4c6ae4978cbde84228d893242 100644 --- a/spec/frontend/vue_shared/components/dropdown/dropdown_search_input_spec.js +++ b/spec/frontend/vue_shared/components/dropdown/dropdown_search_input_spec.js @@ -49,7 +49,9 @@ describe('DropdownSearchInputComponent', () => { wrapper.setProps({ focused: true }); - expect(inputEl.focus).toHaveBeenCalled(); + return wrapper.vm.$nextTick().then(() => { + expect(inputEl.focus).toHaveBeenCalled(); + }); }); }); }); diff --git a/spec/frontend/vue_shared/components/expand_button_spec.js b/spec/frontend/vue_shared/components/expand_button_spec.js index a501e6695d500c69950116d5dd908d62a07b2841..3b1c8f6219cdf62182948ba49b8f39ee648540f7 100644 --- a/spec/frontend/vue_shared/components/expand_button_spec.js +++ b/spec/frontend/vue_shared/components/expand_button_spec.js @@ -1,5 +1,5 @@ import Vue from 'vue'; -import { mount, createLocalVue } from '@vue/test-utils'; +import { mount } from '@vue/test-utils'; import ExpandButton from '~/vue_shared/components/expand_button.vue'; const text = { @@ -14,10 +14,7 @@ describe('Expand button', () => { const expanderAppendEl = () => wrapper.find('.js-text-expander-append'); const factory = (options = {}) => { - const localVue = createLocalVue(); - - wrapper = mount(localVue.extend(ExpandButton), { - localVue, + wrapper = mount(ExpandButton, { ...options, }); }; @@ -136,7 +133,10 @@ describe('Expand button', () => { it('clicking hides itself and shows prepend', () => { expect(expanderAppendEl().isVisible()).toBe(true); expanderAppendEl().trigger('click'); - expect(expanderPrependEl().isVisible()).toBe(true); + + return wrapper.vm.$nextTick().then(() => { + expect(expanderPrependEl().isVisible()).toBe(true); + }); }); it('clicking hides expanded text', () => { @@ -147,12 +147,15 @@ describe('Expand button', () => { .trim(), ).toBe(text.expanded); expanderAppendEl().trigger('click'); - expect( - wrapper - .find(ExpandButton) - .text() - .trim(), - ).not.toBe(text.expanded); + + return wrapper.vm.$nextTick().then(() => { + expect( + wrapper + .find(ExpandButton) + .text() + .trim(), + ).not.toBe(text.expanded); + }); }); describe('when short text is provided', () => { @@ -176,12 +179,15 @@ describe('Expand button', () => { .trim(), ).toBe(text.expanded); expanderAppendEl().trigger('click'); - expect( - wrapper - .find(ExpandButton) - .text() - .trim(), - ).toBe(text.short); + + return wrapper.vm.$nextTick().then(() => { + expect( + wrapper + .find(ExpandButton) + .text() + .trim(), + ).toBe(text.short); + }); }); }); }); diff --git a/spec/frontend/vue_shared/components/file_icon_spec.js b/spec/frontend/vue_shared/components/file_icon_spec.js index f8f68a6a77a0d7f438fb9489890fffd806a5d499..7b7633a06d64b013c7a19b8971db4eba475707e0 100644 --- a/spec/frontend/vue_shared/components/file_icon_spec.js +++ b/spec/frontend/vue_shared/components/file_icon_spec.js @@ -14,7 +14,6 @@ describe('File Icon component', () => { const createComponent = (props = {}) => { wrapper = shallowMount(FileIcon, { - sync: false, propsData: { ...props }, }); }; diff --git a/spec/javascripts/vue_shared/components/gl_modal_vuex_spec.js b/spec/frontend/vue_shared/components/gl_modal_vuex_spec.js similarity index 89% rename from spec/javascripts/vue_shared/components/gl_modal_vuex_spec.js rename to spec/frontend/vue_shared/components/gl_modal_vuex_spec.js index eb78d37db3eef2d2175c5146e974830bcaa5aa98..8437e68d73c9fff9dc13d552bbb7b7938fcd1c83 100644 --- a/spec/javascripts/vue_shared/components/gl_modal_vuex_spec.js +++ b/spec/frontend/vue_shared/components/gl_modal_vuex_spec.js @@ -33,7 +33,7 @@ describe('GlModalVuex', () => { ...options.propsData, }; - wrapper = shallowMount(localVue.extend(GlModalVuex), { + wrapper = shallowMount(GlModalVuex, { ...options, localVue, store, @@ -45,8 +45,8 @@ describe('GlModalVuex', () => { state = createState(); actions = { - show: jasmine.createSpy('show'), - hide: jasmine.createSpy('hide'), + show: jest.fn(), + hide: jest.fn(), }; }); @@ -81,7 +81,7 @@ describe('GlModalVuex', () => { }); it('passes listeners through to gl-modal', () => { - const ok = jasmine.createSpy('ok'); + const ok = jest.fn(); factory({ listeners: { ok }, @@ -119,12 +119,12 @@ describe('GlModalVuex', () => { state.isVisible = false; factory(); - const rootEmit = spyOn(wrapper.vm.$root, '$emit'); + const rootEmit = jest.spyOn(wrapper.vm.$root, '$emit'); state.isVisible = true; - localVue - .nextTick() + wrapper.vm + .$nextTick() .then(() => { expect(rootEmit).toHaveBeenCalledWith('bv::show::modal', TEST_MODAL_ID); }) @@ -136,12 +136,12 @@ describe('GlModalVuex', () => { state.isVisible = true; factory(); - const rootEmit = spyOn(wrapper.vm.$root, '$emit'); + const rootEmit = jest.spyOn(wrapper.vm.$root, '$emit'); state.isVisible = false; - localVue - .nextTick() + wrapper.vm + .$nextTick() .then(() => { expect(rootEmit).toHaveBeenCalledWith('bv::hide::modal', TEST_MODAL_ID); }) diff --git a/spec/frontend/vue_shared/components/gl_toggle_vuex_spec.js b/spec/frontend/vue_shared/components/gl_toggle_vuex_spec.js index d9badffb50dab0f58974c4bdfc6f255d6a82d800..30afb044bbf9189fc447a5241da2dd221325043a 100644 --- a/spec/frontend/vue_shared/components/gl_toggle_vuex_spec.js +++ b/spec/frontend/vue_shared/components/gl_toggle_vuex_spec.js @@ -20,7 +20,6 @@ describe('GlToggleVuex component', () => { stateProperty: 'toggleState', ...props, }, - sync: false, }); }; diff --git a/spec/frontend/vue_shared/components/issue/issue_assignees_spec.js b/spec/frontend/vue_shared/components/issue/issue_assignees_spec.js index dcae2f12833f795fd1c1f8366c32456f3a81a609..b00261ae06763ca87708b88e801e135c93cd8205 100644 --- a/spec/frontend/vue_shared/components/issue/issue_assignees_spec.js +++ b/spec/frontend/vue_shared/components/issue/issue_assignees_spec.js @@ -17,8 +17,6 @@ describe('IssueAssigneesComponent', () => { assignees: mockAssigneesList, ...props, }, - sync: false, - attachToDocument: true, }); vm = wrapper.vm; // eslint-disable-line }; @@ -66,7 +64,7 @@ describe('IssueAssigneesComponent', () => { expect(findOverflowCounter().exists()).toBe(true); expect(findOverflowCounter().text()).toEqual(expectedHidden.toString()); - expect(findOverflowCounter().attributes('data-original-title')).toEqual( + expect(findOverflowCounter().attributes('title')).toEqual( `${hiddenCount} more assignees`, ); }); diff --git a/spec/frontend/vue_shared/components/issue/issue_milestone_spec.js b/spec/frontend/vue_shared/components/issue/issue_milestone_spec.js index 4a66330ac304b784bc00330566d2128158b584a0..4c654e01f7440b3ed5dd1ffa291177d59283129d 100644 --- a/spec/frontend/vue_shared/components/issue/issue_milestone_spec.js +++ b/spec/frontend/vue_shared/components/issue/issue_milestone_spec.js @@ -13,8 +13,6 @@ const createComponent = (milestone = mockMilestone) => { propsData: { milestone, }, - sync: false, - attachToDocument: true, }); }; diff --git a/spec/frontend/vue_shared/components/issue/related_issuable_item_spec.js b/spec/frontend/vue_shared/components/issue/related_issuable_item_spec.js index 3cc640cb00d9f35e7002a4a0eb197123d56426a2..f7b1f041ef23e3dd661d0547cd92a64c79f93ef7 100644 --- a/spec/frontend/vue_shared/components/issue/related_issuable_item_spec.js +++ b/spec/frontend/vue_shared/components/issue/related_issuable_item_spec.js @@ -1,5 +1,5 @@ import Vue from 'vue'; -import { mount, createLocalVue } from '@vue/test-utils'; +import { mount } from '@vue/test-utils'; import { formatDate } from '~/lib/utils/datetime_utility'; import RelatedIssuableItem from '~/vue_shared/components/issue/related_issuable_item.vue'; import { @@ -29,13 +29,8 @@ describe('RelatedIssuableItem', () => { }; beforeEach(() => { - const localVue = createLocalVue(); - - wrapper = mount(localVue.extend(RelatedIssuableItem), { - localVue, + wrapper = mount(RelatedIssuableItem, { slots, - sync: false, - attachToDocument: true, propsData: props, }); }); @@ -192,10 +187,12 @@ describe('RelatedIssuableItem', () => { it('triggers onRemoveRequest when clicked', () => { removeBtn.trigger('click'); - const { relatedIssueRemoveRequest } = wrapper.emitted(); + return wrapper.vm.$nextTick().then(() => { + const { relatedIssueRemoveRequest } = wrapper.emitted(); - expect(relatedIssueRemoveRequest.length).toBe(1); - expect(relatedIssueRemoveRequest[0]).toEqual([props.idKey]); + expect(relatedIssueRemoveRequest.length).toBe(1); + expect(relatedIssueRemoveRequest[0]).toEqual([props.idKey]); + }); }); }); }); diff --git a/spec/frontend/vue_shared/components/loading_button_spec.js b/spec/frontend/vue_shared/components/loading_button_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..8bcb80d140ef80cb1ae28d1d1ed4fa69a3808ba6 --- /dev/null +++ b/spec/frontend/vue_shared/components/loading_button_spec.js @@ -0,0 +1,100 @@ +import { shallowMount } from '@vue/test-utils'; +import LoadingButton from '~/vue_shared/components/loading_button.vue'; + +const LABEL = 'Hello'; + +describe('LoadingButton', () => { + let wrapper; + + afterEach(() => { + wrapper.destroy(); + }); + + const buildWrapper = (propsData = {}) => { + wrapper = shallowMount(LoadingButton, { + propsData, + }); + }; + const findButtonLabel = () => wrapper.find('.js-loading-button-label'); + const findButtonIcon = () => wrapper.find('.js-loading-button-icon'); + + describe('loading spinner', () => { + it('shown when loading', () => { + buildWrapper({ loading: true }); + + expect(findButtonIcon().exists()).toBe(true); + }); + }); + + describe('disabled state', () => { + it('disabled when loading', () => { + buildWrapper({ loading: true }); + expect(wrapper.attributes('disabled')).toBe('disabled'); + }); + + it('not disabled when normal', () => { + buildWrapper({ loading: false }); + + expect(wrapper.attributes('disabled')).toBe(undefined); + }); + }); + + describe('label', () => { + it('shown when normal', () => { + buildWrapper({ loading: false, label: LABEL }); + expect(findButtonLabel().text()).toBe(LABEL); + }); + + it('shown when loading', () => { + buildWrapper({ loading: false, label: LABEL }); + expect(findButtonLabel().text()).toBe(LABEL); + }); + }); + + describe('container class', () => { + it('should default to btn btn-align-content', () => { + buildWrapper(); + + expect(wrapper.classes()).toContain('btn'); + expect(wrapper.classes()).toContain('btn-align-content'); + }); + + it('should be configurable through props', () => { + const containerClass = 'test-class'; + + buildWrapper({ + containerClass, + }); + + expect(wrapper.classes()).not.toContain('btn'); + expect(wrapper.classes()).not.toContain('btn-align-content'); + expect(wrapper.classes()).toContain(containerClass); + }); + }); + + describe('click callback prop', () => { + it('calls given callback when normal', () => { + buildWrapper({ + loading: false, + }); + + wrapper.trigger('click'); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.emitted('click')).toBeTruthy(); + }); + }); + + it('does not call given callback when disabled because of loading', () => { + buildWrapper({ + loading: true, + }); + + wrapper.trigger('click'); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.emitted('click')).toBeFalsy(); + }); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/markdown/field_spec.js b/spec/frontend/vue_shared/components/markdown/field_spec.js index 4cd0f62da0f1f2cfd94497729f73c21e1749f214..46e269e5071f1bd0ea555c0613e1d512c83d46cb 100644 --- a/spec/frontend/vue_shared/components/markdown/field_spec.js +++ b/spec/frontend/vue_shared/components/markdown/field_spec.js @@ -1,9 +1,9 @@ -import { mount, createLocalVue } from '@vue/test-utils'; -import { TEST_HOST } from 'spec/test_constants'; +import { mount } from '@vue/test-utils'; +import fieldComponent from '~/vue_shared/components/markdown/field.vue'; +import { TEST_HOST, FIXTURES_PATH } from 'spec/test_constants'; import AxiosMockAdapter from 'axios-mock-adapter'; import $ from 'jquery'; import axios from '~/lib/utils/axios_utils'; -import fieldComponent from '~/vue_shared/components/markdown/field.vue'; const markdownPreviewPath = `${TEST_HOST}/preview`; const markdownDocsPath = `${TEST_HOST}/docs`; @@ -19,6 +19,7 @@ function createComponent() { propsData: { markdownDocsPath, markdownPreviewPath, + isSubmitting: false, }, slots: { textarea: '<textarea>testing\n123</textarea>', @@ -27,6 +28,7 @@ function createComponent() { <field-component markdown-preview-path="${markdownPreviewPath}" markdown-docs-path="${markdownDocsPath}" + :isSubmitting="false" > <textarea slot="textarea" @@ -35,7 +37,6 @@ function createComponent() { </textarea> </field-component> `, - sync: false, }); return wrapper; } @@ -44,10 +45,10 @@ const getPreviewLink = wrapper => wrapper.find('.nav-links .js-preview-link'); const getWriteLink = wrapper => wrapper.find('.nav-links .js-write-link'); const getMarkdownButton = wrapper => wrapper.find('.js-md'); const getAllMarkdownButtons = wrapper => wrapper.findAll('.js-md'); +const getVideo = wrapper => wrapper.find('video'); describe('Markdown field component', () => { let axiosMock; - const localVue = createLocalVue(); beforeEach(() => { axiosMock = new AxiosMockAdapter(axios); @@ -59,7 +60,10 @@ describe('Markdown field component', () => { describe('mounted', () => { let wrapper; - const previewHTML = '<p>markdown preview</p>'; + const previewHTML = ` + <p>markdown preview</p> + <video src="${FIXTURES_PATH}/static/mock-video.mp4" muted="muted"></video> + `; let previewLink; let writeLink; @@ -78,7 +82,7 @@ describe('Markdown field component', () => { previewLink = getPreviewLink(wrapper); previewLink.trigger('click'); - return localVue.nextTick().then(() => { + return wrapper.vm.$nextTick().then(() => { expect(previewLink.element.parentNode.classList.contains('active')).toBeTruthy(); }); }); @@ -88,7 +92,7 @@ describe('Markdown field component', () => { previewLink = getPreviewLink(wrapper); previewLink.trigger('click'); - localVue.nextTick(() => { + wrapper.vm.$nextTick(() => { expect(wrapper.find('.md-preview-holder').element.textContent.trim()).toContain( 'Loading…', ); @@ -112,9 +116,35 @@ describe('Markdown field component', () => { previewLink.trigger('click'); - setTimeout(() => { - expect($.fn.renderGFM).toHaveBeenCalled(); - }, 0); + return axios.waitFor(markdownPreviewPath).then(() => { + expect(wrapper.find('.md-preview-holder').element.innerHTML).toContain(previewHTML); + }); + }); + + it('calls video.pause() on comment input when isSubmitting is changed to true', () => { + wrapper = createComponent(); + previewLink = getPreviewLink(wrapper); + previewLink.trigger('click'); + + let callPause; + + return axios + .waitFor(markdownPreviewPath) + .then(() => { + const video = getVideo(wrapper); + callPause = jest.spyOn(video.element, 'pause').mockImplementation(() => true); + + wrapper.setProps({ + isSubmitting: true, + markdownPreviewPath, + markdownDocsPath, + }); + + return wrapper.vm.$nextTick(); + }) + .then(() => { + expect(callPause).toHaveBeenCalled(); + }); }); it('clicking already active write or preview link does nothing', () => { @@ -123,17 +153,17 @@ describe('Markdown field component', () => { previewLink = getPreviewLink(wrapper); writeLink.trigger('click'); - return localVue - .nextTick() + return wrapper.vm + .$nextTick() .then(() => assertMarkdownTabs(true, writeLink, previewLink, wrapper)) .then(() => writeLink.trigger('click')) - .then(() => localVue.nextTick()) + .then(() => wrapper.vm.$nextTick()) .then(() => assertMarkdownTabs(true, writeLink, previewLink, wrapper)) .then(() => previewLink.trigger('click')) - .then(() => localVue.nextTick()) + .then(() => wrapper.vm.$nextTick()) .then(() => assertMarkdownTabs(false, writeLink, previewLink, wrapper)) .then(() => previewLink.trigger('click')) - .then(() => localVue.nextTick()) + .then(() => wrapper.vm.$nextTick()) .then(() => assertMarkdownTabs(false, writeLink, previewLink, wrapper)); }); }); @@ -146,7 +176,7 @@ describe('Markdown field component', () => { const markdownButton = getMarkdownButton(wrapper); markdownButton.trigger('click'); - localVue.nextTick(() => { + wrapper.vm.$nextTick(() => { expect(textarea.value).toContain('**testing**'); }); }); @@ -158,7 +188,7 @@ describe('Markdown field component', () => { const markdownButton = getAllMarkdownButtons(wrapper).wrappers[5]; markdownButton.trigger('click'); - localVue.nextTick(() => { + wrapper.vm.$nextTick(() => { expect(textarea.value).toContain('* testing'); }); }); @@ -170,7 +200,7 @@ describe('Markdown field component', () => { const markdownButton = getAllMarkdownButtons(wrapper).wrappers[5]; markdownButton.trigger('click'); - localVue.nextTick(() => { + wrapper.vm.$nextTick(() => { expect(textarea.value).toContain('* testing\n* 123'); }); }); diff --git a/spec/frontend/vue_shared/components/markdown/header_spec.js b/spec/frontend/vue_shared/components/markdown/header_spec.js index 1014fbf0308e8d0f38f3e8f00e40280db4c45084..551d781d29652fc29475cc47dfd1d85e81ebac3d 100644 --- a/spec/frontend/vue_shared/components/markdown/header_spec.js +++ b/spec/frontend/vue_shared/components/markdown/header_spec.js @@ -12,8 +12,6 @@ describe('Markdown field header component', () => { previewMarkdown: false, ...props, }, - sync: false, - attachToDocument: true, }); }; @@ -66,11 +64,17 @@ describe('Markdown field header component', () => { it('emits toggle markdown event when clicking preview', () => { wrapper.find('.js-preview-link').trigger('click'); - expect(wrapper.emitted('preview-markdown').length).toEqual(1); - - wrapper.find('.js-write-link').trigger('click'); - - expect(wrapper.emitted('write-markdown').length).toEqual(1); + return wrapper.vm + .$nextTick() + .then(() => { + expect(wrapper.emitted('preview-markdown').length).toEqual(1); + + wrapper.find('.js-write-link').trigger('click'); + return wrapper.vm.$nextTick(); + }) + .then(() => { + expect(wrapper.emitted('write-markdown').length).toEqual(1); + }); }); it('does not emit toggle markdown event when triggered from another form', () => { diff --git a/spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js b/spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js index 71f9b5e324443c2be38ea50d5bb957e4b813d46e..9b9c3d559e3c8be91a4713f697f99e8e3462a110 100644 --- a/spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js +++ b/spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js @@ -1,9 +1,7 @@ import { GlLoadingIcon } from '@gitlab/ui'; -import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; import SuggestionDiffHeader from '~/vue_shared/components/markdown/suggestion_diff_header.vue'; -const localVue = createLocalVue(); - const DEFAULT_PROPS = { canApply: true, isApplied: false, @@ -14,14 +12,11 @@ describe('Suggestion Diff component', () => { let wrapper; const createComponent = props => { - wrapper = shallowMount(localVue.extend(SuggestionDiffHeader), { + wrapper = shallowMount(SuggestionDiffHeader, { propsData: { ...DEFAULT_PROPS, ...props, }, - localVue, - sync: false, - attachToDocument: true, }); }; diff --git a/spec/frontend/vue_shared/components/markdown/suggestion_diff_row_spec.js b/spec/frontend/vue_shared/components/markdown/suggestion_diff_row_spec.js index c8deac1c0868f77d226aa5fb9e8f4566a35ffd5a..97fcdc67791bcdede41be3cdc1372ff995614854 100644 --- a/spec/frontend/vue_shared/components/markdown/suggestion_diff_row_spec.js +++ b/spec/frontend/vue_shared/components/markdown/suggestion_diff_row_spec.js @@ -1,4 +1,4 @@ -import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; import SuggestionDiffRow from '~/vue_shared/components/markdown/suggestion_diff_row.vue'; const oldLine = { @@ -7,8 +7,8 @@ const oldLine = { meta_data: null, new_line: null, old_line: 5, - rich_text: '-oldtext', - text: '-oldtext', + rich_text: 'oldrichtext', + text: 'oldplaintext', type: 'old', }; @@ -18,8 +18,8 @@ const newLine = { meta_data: null, new_line: 6, old_line: null, - rich_text: '-newtext', - text: '-newtext', + rich_text: 'newrichtext', + text: 'newplaintext', type: 'new', }; @@ -27,10 +27,7 @@ describe('SuggestionDiffRow', () => { let wrapper; const factory = (options = {}) => { - const localVue = createLocalVue(); - wrapper = shallowMount(SuggestionDiffRow, { - localVue, ...options, }); }; @@ -42,14 +39,46 @@ describe('SuggestionDiffRow', () => { wrapper.destroy(); }); - it('renders correctly', () => { - factory({ - propsData: { - line: oldLine, - }, + describe('renders correctly', () => { + it('has the right classes on the wrapper', () => { + factory({ + propsData: { + line: oldLine, + }, + }); + + expect(wrapper.is('.line_holder')).toBe(true); + }); + + it('renders the rich text when it is available', () => { + factory({ + propsData: { + line: newLine, + }, + }); + + expect(wrapper.find('td.line_content').text()).toEqual('newrichtext'); + }); + + it('renders the plain text when it is available but rich text is not', () => { + factory({ + propsData: { + line: Object.assign({}, newLine, { rich_text: undefined }), + }, + }); + + expect(wrapper.find('td.line_content').text()).toEqual('newplaintext'); }); - expect(wrapper.is('.line_holder')).toBe(true); + it('renders a zero-width space when it has no plain or rich texts', () => { + factory({ + propsData: { + line: Object.assign({}, newLine, { rich_text: undefined, text: undefined }), + }, + }); + + expect(wrapper.find('td.line_content').text()).toEqual('\u200B'); + }); }); describe('when passed line has type old', () => { diff --git a/spec/javascripts/vue_shared/components/markdown/suggestion_diff_spec.js b/spec/frontend/vue_shared/components/markdown/suggestion_diff_spec.js similarity index 97% rename from spec/javascripts/vue_shared/components/markdown/suggestion_diff_spec.js rename to spec/frontend/vue_shared/components/markdown/suggestion_diff_spec.js index dc929e83eb755e1b274d5e44a41f7a6917b123ab..3c5e7500ba7ee376924f9742de9e7ecc719f0163 100644 --- a/spec/javascripts/vue_shared/components/markdown/suggestion_diff_spec.js +++ b/spec/frontend/vue_shared/components/markdown/suggestion_diff_spec.js @@ -92,7 +92,7 @@ describe('Suggestion Diff component', () => { describe('applySuggestion', () => { it('emits apply event when applySuggestion is called', () => { const callback = () => {}; - spyOn(vm, '$emit'); + jest.spyOn(vm, '$emit').mockImplementation(() => {}); vm.applySuggestion(callback); expect(vm.$emit).toHaveBeenCalledWith('apply', { suggestionId: vm.suggestion.id, callback }); diff --git a/spec/frontend/vue_shared/components/modal_copy_button_spec.js b/spec/frontend/vue_shared/components/modal_copy_button_spec.js index 3c71cb16bd5b85237c529645be5f684a7e6c2cb4..e5a8860f42e9de6f4779a27281445445aa9ba8c8 100644 --- a/spec/frontend/vue_shared/components/modal_copy_button_spec.js +++ b/spec/frontend/vue_shared/components/modal_copy_button_spec.js @@ -16,8 +16,6 @@ describe('modal copy button', () => { text: 'copy me', title: 'Copy this value', }, - attachToDocument: true, - sync: false, }); }); @@ -29,14 +27,20 @@ describe('modal copy button', () => { removeAllRanges: jest.fn(), })); wrapper.trigger('click'); - expect(wrapper.emitted().success).not.toBeEmpty(); - expect(document.execCommand).toHaveBeenCalledWith('copy'); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.emitted().success).not.toBeEmpty(); + expect(document.execCommand).toHaveBeenCalledWith('copy'); + }); }); it("should propagate the clipboard error event if execCommand doesn't work", () => { document.execCommand = jest.fn(() => false); wrapper.trigger('click'); - expect(wrapper.emitted().error).not.toBeEmpty(); - expect(document.execCommand).toHaveBeenCalledWith('copy'); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.emitted().error).not.toBeEmpty(); + expect(document.execCommand).toHaveBeenCalledWith('copy'); + }); }); }); }); diff --git a/spec/frontend/vue_shared/components/notes/system_note_spec.js b/spec/frontend/vue_shared/components/notes/system_note_spec.js index 603c37c6c491af7b770e98920462dee1a3059386..d5eac7c2aa3e11132c44174eb5f47aa336af0944 100644 --- a/spec/frontend/vue_shared/components/notes/system_note_spec.js +++ b/spec/frontend/vue_shared/components/notes/system_note_spec.js @@ -1,12 +1,10 @@ -import { createLocalVue, mount } from '@vue/test-utils'; +import { mount } from '@vue/test-utils'; import IssueSystemNote from '~/vue_shared/components/notes/system_note.vue'; import createStore from '~/notes/stores'; import initMRPopovers from '~/mr_popover/index'; jest.mock('~/mr_popover/index', () => jest.fn()); -const localVue = createLocalVue(); - describe('system note component', () => { let vm; let props; @@ -34,10 +32,7 @@ describe('system note component', () => { vm = mount(IssueSystemNote, { store, - localVue, propsData: props, - attachToDocument: true, - sync: false, }); }); diff --git a/spec/frontend/vue_shared/components/notes/timeline_entry_item_spec.js b/spec/frontend/vue_shared/components/notes/timeline_entry_item_spec.js index be6c58f0683601f1bb452f3ed5a42898459aa711..f73d3edec5d7973c4308bdbd01147b5bade38591 100644 --- a/spec/frontend/vue_shared/components/notes/timeline_entry_item_spec.js +++ b/spec/frontend/vue_shared/components/notes/timeline_entry_item_spec.js @@ -1,14 +1,11 @@ -import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; describe(`TimelineEntryItem`, () => { let wrapper; const factory = (options = {}) => { - const localVue = createLocalVue(); - wrapper = shallowMount(TimelineEntryItem, { - localVue, ...options, }); }; diff --git a/spec/frontend/vue_shared/components/paginated_list_spec.js b/spec/frontend/vue_shared/components/paginated_list_spec.js index 4e1b29a4d3a9d94e806165624722fa840034db72..46e45296c375405285b34b99b83af99741ead309 100644 --- a/spec/frontend/vue_shared/components/paginated_list_spec.js +++ b/spec/frontend/vue_shared/components/paginated_list_spec.js @@ -26,8 +26,6 @@ describe('Pagination links component', () => { list: [{ id: 'foo' }, { id: 'bar' }], props, }, - attachToDocument: true, - sync: false, }); [glPaginatedList] = wrapper.vm.$children; diff --git a/spec/frontend/vue_shared/components/pagination_links_spec.js b/spec/frontend/vue_shared/components/pagination_links_spec.js index efa5825d92ffa73c7d21bd20079367ee78e978b7..bf004c83c4fb53ff6bcc2e926ce6a4b0411546c7 100644 --- a/spec/frontend/vue_shared/components/pagination_links_spec.js +++ b/spec/frontend/vue_shared/components/pagination_links_spec.js @@ -1,4 +1,4 @@ -import { mount, createLocalVue } from '@vue/test-utils'; +import { mount } from '@vue/test-utils'; import { GlPagination } from '@gitlab/ui'; import PaginationLinks from '~/vue_shared/components/pagination_links.vue'; import { @@ -10,8 +10,6 @@ import { LABEL_LAST_PAGE, } from '~/vue_shared/components/pagination/constants'; -const localVue = createLocalVue(); - describe('Pagination links component', () => { const pageInfo = { page: 3, @@ -38,8 +36,6 @@ describe('Pagination links component', () => { change: changeMock, pageInfo, }, - localVue, - sync: false, }); }; diff --git a/spec/frontend/vue_shared/components/recaptcha_modal_spec.js b/spec/frontend/vue_shared/components/recaptcha_modal_spec.js index e509fe09d9463ef43210c01cef817aacc8cc6ac9..223e7187d99b485567467039b04af85cbd30cc82 100644 --- a/spec/frontend/vue_shared/components/recaptcha_modal_spec.js +++ b/spec/frontend/vue_shared/components/recaptcha_modal_spec.js @@ -14,7 +14,6 @@ describe('RecaptchaModal', () => { beforeEach(() => { wrapper = shallowMount(RecaptchaModal, { - sync: false, propsData: { html: recaptchaHtml, }, diff --git a/spec/frontend/vue_shared/components/resizable_chart_container_spec.js b/spec/frontend/vue_shared/components/resizable_chart_container_spec.js index 552cfade7b6ecfa6b5283af024dbed8ac90bcfe9..3a5514ef3183df0aa98fad358ef872ef1ed2cc9d 100644 --- a/spec/frontend/vue_shared/components/resizable_chart_container_spec.js +++ b/spec/frontend/vue_shared/components/resizable_chart_container_spec.js @@ -14,7 +14,6 @@ describe('Resizable Chart Container', () => { beforeEach(() => { wrapper = mount(ResizableChartContainer, { - attachToDocument: true, scopedSlots: { default: ` <div class="slot" slot-scope="{ width, height }"> diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select/base_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select/base_spec.js index da22034a8dbec72b53595cf077ec666f22e77b8f..d90fafb6bf7cb290b3c21c80ec2decef62230c17 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select/base_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select/base_spec.js @@ -12,8 +12,6 @@ import { const createComponent = (config = mockConfig) => shallowMount(BaseComponent, { propsData: config, - sync: false, - attachToDocument: true, }); describe('BaseComponent', () => { diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_value_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_value_spec.js index 52c0298603d58f126ffce6fc591cf7802e03edbd..54ad96073c85a3a6baa63ff35047b6d8cf7e8a8a 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_value_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_value_spec.js @@ -24,8 +24,6 @@ const createComponent = ( labelFilterBasePath, enableScopedLabels: true, }, - attachToDocument: true, - sync: false, }); }; diff --git a/spec/frontend/vue_shared/components/slot_switch_spec.js b/spec/frontend/vue_shared/components/slot_switch_spec.js index cff955c05b21d57040751386de54b1b60a16f460..71e6087c272630dece660b6c0cbf7d17742fa27b 100644 --- a/spec/frontend/vue_shared/components/slot_switch_spec.js +++ b/spec/frontend/vue_shared/components/slot_switch_spec.js @@ -14,7 +14,6 @@ describe('SlotSwitch', () => { wrapper = shallowMount(SlotSwitch, { propsData, slots, - sync: false, }); }; diff --git a/spec/frontend/vue_shared/components/split_button_spec.js b/spec/frontend/vue_shared/components/split_button_spec.js index 520abb02cf7fc6e2412c46716dbcabd80e901e58..e09bc07304289ae40e198165b7890eca1d436af6 100644 --- a/spec/frontend/vue_shared/components/split_button_spec.js +++ b/spec/frontend/vue_shared/components/split_button_spec.js @@ -22,7 +22,6 @@ describe('SplitButton', () => { const createComponent = propsData => { wrapper = shallowMount(SplitButton, { propsData, - sync: false, }); }; @@ -75,6 +74,7 @@ describe('SplitButton', () => { describe('emitted event', () => { let eventHandler; + let changeEventHandler; beforeEach(() => { createComponent({ actionItems: mockActionItems }); @@ -85,6 +85,11 @@ describe('SplitButton', () => { wrapper.vm.$once(eventName, () => eventHandler()); }; + const addChangeEventHandler = () => { + changeEventHandler = jest.fn(); + wrapper.vm.$once('change', item => changeEventHandler(item)); + }; + it('defaults to first actionItems event', () => { addEventHandler(mockActionItems[0]); @@ -100,5 +105,13 @@ describe('SplitButton', () => { .then(() => { expect(eventHandler).toHaveBeenCalled(); })); + + it('change to selected actionItem emits change event', () => { + addChangeEventHandler(); + + return selectItem(1).then(() => { + expect(changeEventHandler).toHaveBeenCalledWith(mockActionItems[1]); + }); + }); }); }); diff --git a/spec/frontend/vue_shared/components/table_pagination_spec.js b/spec/frontend/vue_shared/components/table_pagination_spec.js index 8105d1fcef36e633097d9e9b482100da8f973ab1..56ffffc7f0fb61d06efbd06c99a3901577f1c7cc 100644 --- a/spec/frontend/vue_shared/components/table_pagination_spec.js +++ b/spec/frontend/vue_shared/components/table_pagination_spec.js @@ -8,7 +8,6 @@ describe('Pagination component', () => { const mountComponent = props => { wrapper = shallowMount(TablePagination, { - sync: false, propsData: props, }); }; diff --git a/spec/frontend/vue_shared/components/time_ago_tooltip_spec.js b/spec/frontend/vue_shared/components/time_ago_tooltip_spec.js index f1f231c1a2920a1a2bf2814ef8349d0e85f86c56..46fcb92455b6e5ccdfbfc78d3de7a2e53fe9f977 100644 --- a/spec/frontend/vue_shared/components/time_ago_tooltip_spec.js +++ b/spec/frontend/vue_shared/components/time_ago_tooltip_spec.js @@ -1,4 +1,4 @@ -import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import { formatDate, getTimeago } from '~/lib/utils/datetime_utility'; @@ -7,10 +7,7 @@ describe('Time ago with tooltip component', () => { const buildVm = (propsData = {}) => { vm = shallowMount(TimeAgoTooltip, { - attachToDocument: true, - sync: false, propsData, - localVue: createLocalVue(), }); }; const timestamp = '2017-05-08T14:57:39.781Z'; @@ -25,7 +22,7 @@ describe('Time ago with tooltip component', () => { }); const timeago = getTimeago(); - expect(vm.attributes('data-original-title')).toEqual(formatDate(timestamp)); + expect(vm.attributes('title')).toEqual(formatDate(timestamp)); expect(vm.text()).toEqual(timeago.format(timestamp)); }); diff --git a/spec/frontend/vue_shared/components/user_avatar/user_avatar_image_spec.js b/spec/frontend/vue_shared/components/user_avatar/user_avatar_image_spec.js index e76b2ca2d658519763aaa5e585eaa1fbbc6cf5f6..663d0af4cc43fbe8159bb0e06cec4e4d9efcb421 100644 --- a/spec/frontend/vue_shared/components/user_avatar/user_avatar_image_spec.js +++ b/spec/frontend/vue_shared/components/user_avatar/user_avatar_image_spec.js @@ -27,7 +27,6 @@ describe('User Avatar Image Component', () => { propsData: { ...DEFAULT_PROPS, }, - sync: false, }); }); @@ -54,7 +53,6 @@ describe('User Avatar Image Component', () => { ...DEFAULT_PROPS, lazy: true, }, - sync: false, }); }); @@ -69,7 +67,7 @@ describe('User Avatar Image Component', () => { describe('Initialization without src', () => { beforeEach(() => { - wrapper = shallowMount(UserAvatarImage, { sync: false }); + wrapper = shallowMount(UserAvatarImage); }); it('should have default avatar image', () => { @@ -86,7 +84,10 @@ describe('User Avatar Image Component', () => { }; beforeEach(() => { - wrapper = shallowMount(UserAvatarImage, { propsData: { props }, slots, sync: false }); + wrapper = shallowMount(UserAvatarImage, { + propsData: { props }, + slots, + }); }); it('renders the tooltip slot', () => { @@ -100,7 +101,7 @@ describe('User Avatar Image Component', () => { it('does not render tooltip data attributes for on avatar image', () => { const avatarImg = wrapper.find('img'); - expect(avatarImg.attributes('data-original-title')).toBeFalsy(); + expect(avatarImg.attributes('title')).toBeFalsy(); expect(avatarImg.attributes('data-placement')).not.toBeDefined(); expect(avatarImg.attributes('data-container')).not.toBeDefined(); }); diff --git a/spec/frontend/vue_shared/components/user_avatar/user_avatar_link_spec.js b/spec/frontend/vue_shared/components/user_avatar/user_avatar_link_spec.js index 7f5df02d51de0016de11c11164e30ed355401d2e..2f68e15b0d7528b8b4dc74124b294173b0525ce3 100644 --- a/spec/frontend/vue_shared/components/user_avatar/user_avatar_link_spec.js +++ b/spec/frontend/vue_shared/components/user_avatar/user_avatar_link_spec.js @@ -26,8 +26,6 @@ describe('User Avatar Link Component', () => { ...defaultProps, ...props, }, - sync: false, - attachToDocument: true, }); }; @@ -99,9 +97,9 @@ describe('User Avatar Link Component', () => { }); it('should render text tooltip for <span>', () => { - expect( - wrapper.find('.js-user-avatar-link-username').attributes('data-original-title'), - ).toEqual(defaultProps.tooltipText); + expect(wrapper.find('.js-user-avatar-link-username').attributes('title')).toEqual( + defaultProps.tooltipText, + ); }); it('should render text tooltip placement for <span>', () => { diff --git a/spec/javascripts/vue_shared/components/user_avatar/user_avatar_list_spec.js b/spec/frontend/vue_shared/components/user_avatar/user_avatar_list_spec.js similarity index 91% rename from spec/javascripts/vue_shared/components/user_avatar/user_avatar_list_spec.js rename to spec/frontend/vue_shared/components/user_avatar/user_avatar_list_spec.js index 96bc3b0cc17c1897a9bf327c58df91763c48b551..6f66d1cafb9b7677730bd94ac6de8dd5ed49eadc 100644 --- a/spec/javascripts/vue_shared/components/user_avatar/user_avatar_list_spec.js +++ b/spec/frontend/vue_shared/components/user_avatar/user_avatar_list_spec.js @@ -1,4 +1,4 @@ -import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; import { GlButton } from '@gitlab/ui'; import { TEST_HOST } from 'spec/test_constants'; import UserAvatarList from '~/vue_shared/components/user_avatar/user_avatar_list.vue'; @@ -20,8 +20,6 @@ const createList = n => .fill(1) .map((x, id) => createUser(id)); -const localVue = createLocalVue(); - describe('UserAvatarList', () => { let props; let wrapper; @@ -32,9 +30,8 @@ describe('UserAvatarList', () => { ...options.propsData, }; - wrapper = shallowMount(localVue.extend(UserAvatarList), { + wrapper = shallowMount(UserAvatarList, { ...options, - localVue, propsData, }); }; @@ -86,7 +83,7 @@ describe('UserAvatarList', () => { expect(linkProps).toEqual( items.map(x => - jasmine.objectContaining({ + expect.objectContaining({ linkHref: x.web_url, imgSrc: x.avatar_url, imgAlt: x.name, @@ -147,9 +144,12 @@ describe('UserAvatarList', () => { it('with collapse clicked, it renders avatars up to breakpoint', () => { clickButton(); - const links = wrapper.findAll(UserAvatarLink); - expect(links.length).toEqual(TEST_BREAKPOINT); + return wrapper.vm.$nextTick(() => { + const links = wrapper.findAll(UserAvatarLink); + + expect(links.length).toEqual(TEST_BREAKPOINT); + }); }); }); }); diff --git a/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js b/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js index f2e743cc1f6c8955c35c02b17d89a52774f646d8..a8bbc80d2dfaf51175333748a6a8ce5e376a7274 100644 --- a/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js +++ b/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js @@ -29,23 +29,38 @@ describe('User Popover Component', () => { wrapper.destroy(); }); + const findUserStatus = () => wrapper.find('.js-user-status'); + const findTarget = () => document.querySelector('.js-user-link'); + + const createWrapper = (props = {}, options = {}) => { + wrapper = shallowMount(UserPopover, { + propsData: { + ...DEFAULT_PROPS, + target: findTarget(), + ...props, + }, + ...options, + }); + }; + describe('Empty', () => { beforeEach(() => { - wrapper = shallowMount(UserPopover, { - propsData: { - target: document.querySelector('.js-user-link'), - user: { - name: null, - username: null, - location: null, - bio: null, - organization: null, - status: null, + createWrapper( + {}, + { + propsData: { + target: findTarget(), + user: { + name: null, + username: null, + location: null, + bio: null, + organization: null, + status: null, + }, }, }, - attachToDocument: true, - sync: false, - }); + ); }); it('should return skeleton loaders', () => { @@ -55,13 +70,7 @@ describe('User Popover Component', () => { describe('basic data', () => { it('should show basic fields', () => { - wrapper = shallowMount(UserPopover, { - propsData: { - ...DEFAULT_PROPS, - target: document.querySelector('.js-user-link'), - }, - sync: false, - }); + createWrapper(); expect(wrapper.text()).toContain(DEFAULT_PROPS.user.name); expect(wrapper.text()).toContain(DEFAULT_PROPS.user.username); @@ -77,64 +86,38 @@ describe('User Popover Component', () => { describe('job data', () => { it('should show only bio if no organization is available', () => { - const testProps = Object.assign({}, DEFAULT_PROPS); - testProps.user.bio = 'Engineer'; + const user = { ...DEFAULT_PROPS.user, bio: 'Engineer' }; - wrapper = shallowMount(UserPopover, { - propsData: { - ...testProps, - target: document.querySelector('.js-user-link'), - }, - sync: false, - }); + createWrapper({ user }); expect(wrapper.text()).toContain('Engineer'); }); it('should show only organization if no bio is available', () => { - const testProps = Object.assign({}, DEFAULT_PROPS); - testProps.user.organization = 'GitLab'; + const user = { ...DEFAULT_PROPS.user, organization: 'GitLab' }; - wrapper = shallowMount(UserPopover, { - propsData: { - ...testProps, - target: document.querySelector('.js-user-link'), - }, - sync: false, - }); + createWrapper({ user }); expect(wrapper.text()).toContain('GitLab'); }); it('should display bio and organization in separate lines', () => { - const testProps = Object.assign({}, DEFAULT_PROPS); - testProps.user.bio = 'Engineer'; - testProps.user.organization = 'GitLab'; - - wrapper = shallowMount(UserPopover, { - propsData: { - ...DEFAULT_PROPS, - target: document.querySelector('.js-user-link'), - }, - sync: false, - }); + const user = { ...DEFAULT_PROPS.user, bio: 'Engineer', organization: 'GitLab' }; + + createWrapper({ user }); expect(wrapper.find('.js-bio').text()).toContain('Engineer'); expect(wrapper.find('.js-organization').text()).toContain('GitLab'); }); it('should not encode special characters in bio and organization', () => { - const testProps = Object.assign({}, DEFAULT_PROPS); - testProps.user.bio = 'Manager & Team Lead'; - testProps.user.organization = 'Me & my <funky> Company'; - - wrapper = shallowMount(UserPopover, { - propsData: { - ...DEFAULT_PROPS, - target: document.querySelector('.js-user-link'), - }, - sync: false, - }); + const user = { + ...DEFAULT_PROPS.user, + bio: 'Manager & Team Lead', + organization: 'Me & my <funky> Company', + }; + + createWrapper({ user }); expect(wrapper.find('.js-bio').text()).toContain('Manager & Team Lead'); expect(wrapper.find('.js-organization').text()).toContain('Me & my <funky> Company'); @@ -153,35 +136,41 @@ describe('User Popover Component', () => { describe('status data', () => { it('should show only message', () => { - const testProps = Object.assign({}, DEFAULT_PROPS); - testProps.user.status = { message_html: 'Hello World' }; + const user = { ...DEFAULT_PROPS.user, status: { message_html: 'Hello World' } }; - wrapper = shallowMount(UserPopover, { - propsData: { - ...DEFAULT_PROPS, - target: document.querySelector('.js-user-link'), - }, - sync: false, - }); + createWrapper({ user }); + expect(findUserStatus().exists()).toBe(true); expect(wrapper.text()).toContain('Hello World'); }); it('should show message and emoji', () => { - const testProps = Object.assign({}, DEFAULT_PROPS); - testProps.user.status = { emoji: 'basketball_player', message_html: 'Hello World' }; - - wrapper = shallowMount(UserPopover, { - propsData: { - ...DEFAULT_PROPS, - target: document.querySelector('.js-user-link'), - status: { emoji: 'basketball_player', message_html: 'Hello World' }, - }, - sync: false, - }); + const user = { + ...DEFAULT_PROPS.user, + status: { emoji: 'basketball_player', message_html: 'Hello World' }, + }; + + createWrapper({ user }); + expect(findUserStatus().exists()).toBe(true); expect(wrapper.text()).toContain('Hello World'); expect(wrapper.html()).toContain('<gl-emoji data-name="basketball_player"'); }); + + it('hides the div when status is null', () => { + const user = { ...DEFAULT_PROPS.user, status: null }; + + createWrapper({ user }); + + expect(findUserStatus().exists()).toBe(false); + }); + + it('hides the div when status is empty', () => { + const user = { ...DEFAULT_PROPS.user, status: { emoji: '', message_html: '' } }; + + createWrapper({ user }); + + expect(findUserStatus().exists()).toBe(false); + }); }); }); diff --git a/spec/frontend/vue_shared/directives/track_event_spec.js b/spec/frontend/vue_shared/directives/track_event_spec.js index d63f6ae05b430e28e3548c58e1d2e3cc8fe46b0c..8d867c8e3fc9a3ad9140eaabb82a5a9676af9898 100644 --- a/spec/frontend/vue_shared/directives/track_event_spec.js +++ b/spec/frontend/vue_shared/directives/track_event_spec.js @@ -1,5 +1,5 @@ import Vue from 'vue'; -import { createLocalVue, shallowMount } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; import Tracking from '~/tracking'; import TrackEvent from '~/vue_shared/directives/track_event'; @@ -17,15 +17,12 @@ const Component = Vue.component('dummy-element', { template: '<button id="trackable" v-track-event="trackingOptions"></button>', }); -const localVue = createLocalVue(); let wrapper; let button; describe('Error Tracking directive', () => { beforeEach(() => { - wrapper = shallowMount(localVue.extend(Component), { - localVue, - }); + wrapper = shallowMount(Component); button = wrapper.find('#trackable'); }); @@ -43,7 +40,10 @@ describe('Error Tracking directive', () => { wrapper.setData({ trackingOptions }); const { category, action, label, property, value } = trackingOptions; - button.trigger('click'); - expect(Tracking.event).toHaveBeenCalledWith(category, action, { label, property, value }); + + return wrapper.vm.$nextTick(() => { + button.trigger('click'); + expect(Tracking.event).toHaveBeenCalledWith(category, action, { label, property, value }); + }); }); }); diff --git a/spec/frontend/vue_shared/droplab_dropdown_button_spec.js b/spec/frontend/vue_shared/droplab_dropdown_button_spec.js index 22295721328a07d96a921d3f07c88b35da70de92..e57c730ecee2c15fd51325899258485a4357ce48 100644 --- a/spec/frontend/vue_shared/droplab_dropdown_button_spec.js +++ b/spec/frontend/vue_shared/droplab_dropdown_button_spec.js @@ -1,4 +1,4 @@ -import { mount, createLocalVue } from '@vue/test-utils'; +import { mount } from '@vue/test-utils'; import DroplabDropdownButton from '~/vue_shared/components/droplab_dropdown_button.vue'; @@ -18,11 +18,8 @@ const createComponent = ({ dropdownClass = '', actions = mockActions, defaultAction = 0, -}) => { - const localVue = createLocalVue(); - - return mount(DroplabDropdownButton, { - localVue, +}) => + mount(DroplabDropdownButton, { propsData: { size, dropdownClass, @@ -30,7 +27,6 @@ const createComponent = ({ defaultAction, }, }); -}; describe('DroplabDropdownButton', () => { let wrapper; diff --git a/spec/frontend/vue_shared/mixins/gl_feature_flags_mixin_spec.js b/spec/frontend/vue_shared/mixins/gl_feature_flags_mixin_spec.js index a3e3270a4e85c6b3b0fab40d103ac391d1182900..3ce12caf95a571e2cb1518dde667d6a5d5c4ce0a 100644 --- a/spec/frontend/vue_shared/mixins/gl_feature_flags_mixin_spec.js +++ b/spec/frontend/vue_shared/mixins/gl_feature_flags_mixin_spec.js @@ -1,8 +1,6 @@ -import { createLocalVue, shallowMount } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -const localVue = createLocalVue(); - describe('GitLab Feature Flags Mixin', () => { let wrapper; @@ -20,7 +18,6 @@ describe('GitLab Feature Flags Mixin', () => { }; wrapper = shallowMount(component, { - localVue, provide: { glFeatures: { ...(gon.features || {}) }, }, diff --git a/spec/frontend/vuex_shared/bindings_spec.js b/spec/frontend/vuex_shared/bindings_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..0f91a09018f7fb81774a6038500761e12b4382e5 --- /dev/null +++ b/spec/frontend/vuex_shared/bindings_spec.js @@ -0,0 +1,79 @@ +import { shallowMount } from '@vue/test-utils'; +import { mapComputed } from '~/vuex_shared/bindings'; + +describe('Binding utils', () => { + describe('mapComputed', () => { + const defaultArgs = [['baz'], 'bar', 'foo']; + + const createDummy = (mapComputedArgs = defaultArgs) => ({ + computed: { + ...mapComputed(...mapComputedArgs), + }, + render() { + return null; + }, + }); + + const mocks = { + $store: { + state: { + baz: 2, + foo: { + baz: 1, + }, + }, + getters: { + getBaz: 'foo', + }, + dispatch: jest.fn(), + }, + }; + + it('returns an object with keys equal to the first fn parameter ', () => { + const keyList = ['foo1', 'foo2']; + const result = mapComputed(keyList, 'foo', 'bar'); + expect(Object.keys(result)).toEqual(keyList); + }); + + it('returned object has set and get function', () => { + const result = mapComputed(['baz'], 'foo', 'bar'); + expect(result.baz.set).toBeDefined(); + expect(result.baz.get).toBeDefined(); + }); + + describe('set function', () => { + it('invokes $store.dispatch', () => { + const context = shallowMount(createDummy(), { mocks }); + context.vm.baz = 'a'; + expect(context.vm.$store.dispatch).toHaveBeenCalledWith('bar', { baz: 'a' }); + }); + it('uses updateFn in list object mode if updateFn exists', () => { + const context = shallowMount(createDummy([[{ key: 'foo', updateFn: 'baz' }]]), { mocks }); + context.vm.foo = 'b'; + expect(context.vm.$store.dispatch).toHaveBeenCalledWith('baz', { foo: 'b' }); + }); + it('in list object mode defaults to defaultUpdateFn if updateFn do not exists', () => { + const context = shallowMount(createDummy([[{ key: 'foo' }], 'defaultFn']), { mocks }); + context.vm.foo = 'c'; + expect(context.vm.$store.dispatch).toHaveBeenCalledWith('defaultFn', { foo: 'c' }); + }); + }); + + describe('get function', () => { + it('if root is set returns $store.state[root][key]', () => { + const context = shallowMount(createDummy(), { mocks }); + expect(context.vm.baz).toBe(mocks.$store.state.foo.baz); + }); + + it('if root is not set returns $store.state[key]', () => { + const context = shallowMount(createDummy([['baz'], 'bar']), { mocks }); + expect(context.vm.baz).toBe(mocks.$store.state.baz); + }); + + it('when using getters it invoke the appropriate getter', () => { + const context = shallowMount(createDummy([[{ getter: 'getBaz', key: 'baz' }]]), { mocks }); + expect(context.vm.baz).toBe(mocks.$store.getters.getBaz); + }); + }); + }); +}); diff --git a/spec/graphql/gitlab_schema_spec.rb b/spec/graphql/gitlab_schema_spec.rb index dcf3c9890478a8c68505ee8f298c6fe94ce28724..2ec477fc494773f81f4c6d4207a4250fc3d0b12c 100644 --- a/spec/graphql/gitlab_schema_spec.rb +++ b/spec/graphql/gitlab_schema_spec.rb @@ -124,14 +124,26 @@ describe GitlabSchema do describe '.object_from_id' do context 'for subclasses of `ApplicationRecord`' do - it 'returns the correct record' do - user = create(:user) + let_it_be(:user) { create(:user) } + it 'returns the correct record' do result = described_class.object_from_id(user.to_global_id.to_s) expect(result.sync).to eq(user) end + it 'returns the correct record, of the expected type' do + result = described_class.object_from_id(user.to_global_id.to_s, expected_type: ::User) + + expect(result.sync).to eq(user) + end + + it 'fails if the type does not match' do + expect do + described_class.object_from_id(user.to_global_id.to_s, expected_type: ::Project) + end.to raise_error(Gitlab::Graphql::Errors::ArgumentError) + end + it 'batchloads the queries' do user1 = create(:user) user2 = create(:user) diff --git a/spec/graphql/resolvers/projects/grafana_integration_resolver_spec.rb b/spec/graphql/resolvers/projects/grafana_integration_resolver_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..416a90a841f8a58f25e883ccb84bb4cfacb8bcf5 --- /dev/null +++ b/spec/graphql/resolvers/projects/grafana_integration_resolver_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Resolvers::Projects::GrafanaIntegrationResolver do + include GraphqlHelpers + + let_it_be(:project) { create(:project) } + let_it_be(:current_user) { create(:user) } + let_it_be(:grafana_integration) { create(:grafana_integration, project: project)} + + describe '#resolve' do + context 'when object is not a project' do + it { expect(resolve_integration(obj: current_user)).to eq nil } + end + + context 'when object is a project' do + it { expect(resolve_integration(obj: project)).to eq grafana_integration } + end + + context 'when object is nil' do + it { expect(resolve_integration(obj: nil)).to eq nil} + end + end + + def resolve_integration(obj: project, context: { current_user: current_user }) + resolve(described_class, obj: obj, ctx: context) + end +end diff --git a/spec/graphql/types/environment_type_spec.rb b/spec/graphql/types/environment_type_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..cf30893b3cada3681182e398a8060b390dc4d9a4 --- /dev/null +++ b/spec/graphql/types/environment_type_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe GitlabSchema.types['Environment'] do + it { expect(described_class.graphql_name).to eq('Environment') } + + it 'has the expected fields' do + expected_fields = %w[ + name id + ] + + is_expected.to have_graphql_fields(*expected_fields) + end + + it { is_expected.to require_graphql_authorizations(:read_environment) } +end diff --git a/spec/graphql/types/error_tracking/sentry_detailed_error_type_spec.rb b/spec/graphql/types/error_tracking/sentry_detailed_error_type_spec.rb index 3576adb5272193111fc95543d946ebe9b84bc31c..30cede6f4cf84cb463ebeb3e3db694fc07754299 100644 --- a/spec/graphql/types/error_tracking/sentry_detailed_error_type_spec.rb +++ b/spec/graphql/types/error_tracking/sentry_detailed_error_type_spec.rb @@ -30,6 +30,8 @@ describe GitlabSchema.types['SentryDetailedError'] do lastReleaseLastCommit firstReleaseShortVersion lastReleaseShortVersion + gitlabCommit + gitlabCommitPath ] is_expected.to have_graphql_fields(*expected_fields) diff --git a/spec/graphql/types/grafana_integration_type_spec.rb b/spec/graphql/types/grafana_integration_type_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..ddfedc5a75c3032fce1f524d4e0149f294c187be --- /dev/null +++ b/spec/graphql/types/grafana_integration_type_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe GitlabSchema.types['GrafanaIntegration'] do + let(:expected_fields) do + %i[ + id + grafana_url + token + enabled + created_at + updated_at + ] + end + + it { expect(described_class.graphql_name).to eq('GrafanaIntegration') } + + it { expect(described_class).to require_graphql_authorizations(:admin_operations) } + + it { is_expected.to have_graphql_fields(*expected_fields) } +end diff --git a/spec/graphql/types/group_type_spec.rb b/spec/graphql/types/group_type_spec.rb index 3dd5b602aa26d91ef6e394de5b79e6babdeb960b..de11bad0723aee52616d0fd8702b63f4ea8e7fa7 100644 --- a/spec/graphql/types/group_type_spec.rb +++ b/spec/graphql/types/group_type_spec.rb @@ -8,4 +8,10 @@ describe GitlabSchema.types['Group'] do it { expect(described_class.graphql_name).to eq('Group') } it { expect(described_class).to require_graphql_authorizations(:read_group) } + + it 'has the expected fields' do + expected_fields = %w[web_url avatar_url mentions_disabled parent] + + is_expected.to include_graphql_fields(*expected_fields) + end end diff --git a/spec/graphql/types/project_type_spec.rb b/spec/graphql/types/project_type_spec.rb index a3c51f24307a303e6f1dff07019ac94185d1379e..ac2d2d6f7f04c9f33ff8b8048e6201f81eb53b2b 100644 --- a/spec/graphql/types/project_type_spec.rb +++ b/spec/graphql/types/project_type_spec.rb @@ -23,6 +23,7 @@ describe GitlabSchema.types['Project'] do only_allow_merge_if_all_discussions_are_resolved printing_merge_request_link_enabled namespace group statistics repository merge_requests merge_request issues issue pipelines removeSourceBranchAfterMerge sentryDetailedError snippets + grafanaIntegration autocloseReferencedIssues suggestion_commit_message environments ] is_expected.to include_graphql_fields(*expected_fields) @@ -31,45 +32,49 @@ describe GitlabSchema.types['Project'] do describe 'issue field' do subject { described_class.fields['issue'] } - it 'returns issue' do - is_expected.to have_graphql_type(Types::IssueType) - is_expected.to have_graphql_resolver(Resolvers::IssuesResolver.single) - end + it { is_expected.to have_graphql_type(Types::IssueType) } + it { is_expected.to have_graphql_resolver(Resolvers::IssuesResolver.single) } end describe 'issues field' do subject { described_class.fields['issues'] } - it 'returns issue' do - is_expected.to have_graphql_type(Types::IssueType.connection_type) - is_expected.to have_graphql_resolver(Resolvers::IssuesResolver) - end + it { is_expected.to have_graphql_type(Types::IssueType.connection_type) } + it { is_expected.to have_graphql_resolver(Resolvers::IssuesResolver) } end describe 'merge_requests field' do subject { described_class.fields['mergeRequest'] } - it 'returns merge requests' do - is_expected.to have_graphql_type(Types::MergeRequestType) - is_expected.to have_graphql_resolver(Resolvers::MergeRequestsResolver.single) - end + it { is_expected.to have_graphql_type(Types::MergeRequestType) } + it { is_expected.to have_graphql_resolver(Resolvers::MergeRequestsResolver.single) } end describe 'merge_request field' do subject { described_class.fields['mergeRequests'] } - it 'returns merge request' do - is_expected.to have_graphql_type(Types::MergeRequestType.connection_type) - is_expected.to have_graphql_resolver(Resolvers::MergeRequestsResolver) - end + it { is_expected.to have_graphql_type(Types::MergeRequestType.connection_type) } + it { is_expected.to have_graphql_resolver(Resolvers::MergeRequestsResolver) } end describe 'snippets field' do subject { described_class.fields['snippets'] } - it 'returns snippets' do - is_expected.to have_graphql_type(Types::SnippetType.connection_type) - is_expected.to have_graphql_resolver(Resolvers::Projects::SnippetsResolver) - end + it { is_expected.to have_graphql_type(Types::SnippetType.connection_type) } + it { is_expected.to have_graphql_resolver(Resolvers::Projects::SnippetsResolver) } + end + + describe 'grafana_integration field' do + subject { described_class.fields['grafanaIntegration'] } + + it { is_expected.to have_graphql_type(Types::GrafanaIntegrationType) } + it { is_expected.to have_graphql_resolver(Resolvers::Projects::GrafanaIntegrationResolver) } + end + + describe 'environments field' do + subject { described_class.fields['environments'] } + + it { is_expected.to have_graphql_type(Types::EnvironmentType.connection_type) } + it { is_expected.to have_graphql_resolver(Resolvers::EnvironmentsResolver) } end end diff --git a/spec/graphql/types/query_type_spec.rb b/spec/graphql/types/query_type_spec.rb index b2d0ba27d4ed5fd5c28c7c211790309f3fd7f4b7..39a363cb913810715213ab9e42c4d28bb706c9dc 100644 --- a/spec/graphql/types/query_type_spec.rb +++ b/spec/graphql/types/query_type_spec.rb @@ -7,7 +7,16 @@ describe GitlabSchema.types['Query'] do expect(described_class.graphql_name).to eq('Query') end - it { is_expected.to have_graphql_fields(:project, :namespace, :group, :echo, :metadata, :current_user, :snippets) } + it do + is_expected.to have_graphql_fields(:project, + :namespace, + :group, + :echo, + :metadata, + :current_user, + :snippets + ).at_least + end describe 'namespace field' do subject { described_class.fields['namespace'] } diff --git a/spec/helpers/application_helper_spec.rb b/spec/helpers/application_helper_spec.rb index a0c85863150fa755f5d8e81cd2039e7d97da49c2..a67475e47a399c4ac709c4ca7a9eb3a47f4d3515 100644 --- a/spec/helpers/application_helper_spec.rb +++ b/spec/helpers/application_helper_spec.rb @@ -206,6 +206,15 @@ describe ApplicationHelper do end end + context 'when @snippet is set' do + it 'returns the passed path' do + snippet = create(:snippet) + assign(:snippet, snippet) + + expect(helper.external_storage_url_or_path('/foo/bar', project)).to eq('/foo/bar') + end + end + context 'when external storage is enabled' do let(:user) { create(:user, static_object_token: 'hunter1') } diff --git a/spec/helpers/application_settings_helper_spec.rb b/spec/helpers/application_settings_helper_spec.rb index 8303c4eafbe146690bf412f94ec8ce119dd37e8e..41008ff8eaf1e301a7bae5705bc8ec0758f348a6 100644 --- a/spec/helpers/application_settings_helper_spec.rb +++ b/spec/helpers/application_settings_helper_spec.rb @@ -59,4 +59,68 @@ describe ApplicationSettingsHelper do expect(helper.integration_expanded?('plantuml_')).to be_falsey end end + + describe '.self_monitoring_project_data' do + context 'when self monitoring project does not exist' do + it 'returns create_self_monitoring_project_path' do + expect(helper.self_monitoring_project_data).to include( + 'create_self_monitoring_project_path' => + create_self_monitoring_project_admin_application_settings_path + ) + end + + it 'returns status_create_self_monitoring_project_path' do + expect(helper.self_monitoring_project_data).to include( + 'status_create_self_monitoring_project_path' => + status_create_self_monitoring_project_admin_application_settings_path + ) + end + + it 'returns delete_self_monitoring_project_path' do + expect(helper.self_monitoring_project_data).to include( + 'delete_self_monitoring_project_path' => + delete_self_monitoring_project_admin_application_settings_path + ) + end + + it 'returns status_delete_self_monitoring_project_path' do + expect(helper.self_monitoring_project_data).to include( + 'status_delete_self_monitoring_project_path' => + status_delete_self_monitoring_project_admin_application_settings_path + ) + end + + it 'returns self_monitoring_project_exists false' do + expect(helper.self_monitoring_project_data).to include( + 'self_monitoring_project_exists' => "false" + ) + end + + it 'returns nil for project full_path' do + expect(helper.self_monitoring_project_data).to include( + 'self_monitoring_project_full_path' => nil + ) + end + end + + context 'when self monitoring project exists' do + let(:project) { build(:project) } + + before do + stub_application_setting(instance_administration_project: project) + end + + it 'returns self_monitoring_project_exists true' do + expect(helper.self_monitoring_project_data).to include( + 'self_monitoring_project_exists' => "true" + ) + end + + it 'returns project full_path' do + expect(helper.self_monitoring_project_data).to include( + 'self_monitoring_project_full_path' => project.full_path + ) + end + end + end end diff --git a/spec/helpers/broadcast_messages_helper_spec.rb b/spec/helpers/broadcast_messages_helper_spec.rb index d0f0e6f1dd5eb88bc95ebc28a0a5914467d448b3..a0682c0e2780729c6d54dae4821813292372979e 100644 --- a/spec/helpers/broadcast_messages_helper_spec.rb +++ b/spec/helpers/broadcast_messages_helper_spec.rb @@ -4,24 +4,22 @@ require 'spec_helper' describe BroadcastMessagesHelper do describe 'broadcast_message' do + let(:current_broadcast_message) { BroadcastMessage.new(message: 'Current Message') } + it 'returns nil when no current message' do expect(helper.broadcast_message(nil)).to be_nil end it 'includes the current message' do - current = BroadcastMessage.new(message: 'Current Message') - allow(helper).to receive(:broadcast_message_style).and_return(nil) - expect(helper.broadcast_message(current)).to include 'Current Message' + expect(helper.broadcast_message(current_broadcast_message)).to include 'Current Message' end it 'includes custom style' do - current = BroadcastMessage.new(message: 'Current Message') - allow(helper).to receive(:broadcast_message_style).and_return('foo') - expect(helper.broadcast_message(current)).to include 'style="foo"' + expect(helper.broadcast_message(current_broadcast_message)).to include 'style="foo"' end end @@ -32,12 +30,18 @@ describe BroadcastMessagesHelper do expect(helper.broadcast_message_style(broadcast_message)).to eq '' end - it 'allows custom style' do - broadcast_message = double(color: '#f2dede', font: '#b94a48') + it 'allows custom style for banner messages' do + broadcast_message = BroadcastMessage.new(color: '#f2dede', font: '#b94a48', broadcast_type: "banner") expect(helper.broadcast_message_style(broadcast_message)) .to match('background-color: #f2dede; color: #b94a48') end + + it 'does not add style for notification messages' do + broadcast_message = BroadcastMessage.new(color: '#f2dede', broadcast_type: "notification") + + expect(helper.broadcast_message_style(broadcast_message)).to eq '' + end end describe 'broadcast_message_status' do diff --git a/spec/helpers/container_expiration_policies_helper_spec.rb b/spec/helpers/container_expiration_policies_helper_spec.rb index 3eb1234d82b6800b0817882b3b705463d88b06c7..f7e851fb012bc3f68d227cca5f8b1af370d17bd6 100644 --- a/spec/helpers/container_expiration_policies_helper_spec.rb +++ b/spec/helpers/container_expiration_policies_helper_spec.rb @@ -8,7 +8,7 @@ describe ContainerExpirationPoliciesHelper do expected_result = [ { key: 1, label: '1 tag per image name' }, { key: 5, label: '5 tags per image name' }, - { key: 10, label: '10 tags per image name' }, + { key: 10, label: '10 tags per image name', default: true }, { key: 25, label: '25 tags per image name' }, { key: 50, label: '50 tags per image name' }, { key: 100, label: '100 tags per image name' } @@ -21,7 +21,7 @@ describe ContainerExpirationPoliciesHelper do describe '#cadence_options' do it 'returns cadence options formatted for dropdown usage' do expected_result = [ - { key: '1d', label: 'Every day' }, + { key: '1d', label: 'Every day', default: true }, { key: '7d', label: 'Every week' }, { key: '14d', label: 'Every two weeks' }, { key: '1month', label: 'Every month' }, @@ -37,7 +37,7 @@ describe ContainerExpirationPoliciesHelper do expected_result = [ { key: '7d', label: '7 days until tags are automatically removed' }, { key: '14d', label: '14 days until tags are automatically removed' }, - { key: '30d', label: '30 days until tags are automatically removed' }, + { key: '30d', label: '30 days until tags are automatically removed', default: true }, { key: '90d', label: '90 days until tags are automatically removed' } ] diff --git a/spec/helpers/environments_helper_spec.rb b/spec/helpers/environments_helper_spec.rb index a50c8e9bf8e1ca89cb7938eb8d7ccd885fadb67c..b7a6cd4db74115d9f089be0babe39accbff14ef6 100644 --- a/spec/helpers/environments_helper_spec.rb +++ b/spec/helpers/environments_helper_spec.rb @@ -3,9 +3,9 @@ require 'spec_helper' describe EnvironmentsHelper do - set(:environment) { create(:environment) } - set(:project) { environment.project } set(:user) { create(:user) } + set(:project) { create(:project, :repository) } + set(:environment) { create(:environment, project: project) } describe '#metrics_data' do before do @@ -28,6 +28,7 @@ describe EnvironmentsHelper do 'empty-unable-to-connect-svg-path' => match_asset_path('/assets/illustrations/monitoring/unable_to_connect.svg'), 'metrics-endpoint' => additional_metrics_project_environment_path(project, environment, format: :json), 'deployments-endpoint' => project_environment_deployments_path(project, environment, format: :json), + 'default-branch' => 'master', 'environments-endpoint': project_environments_path(project, format: :json), 'project-path' => project_path(project), 'tags-path' => project_tags_path(project), diff --git a/spec/helpers/gitlab_routing_helper_spec.rb b/spec/helpers/gitlab_routing_helper_spec.rb index e76ebcb5637dfc89b341371fefeb23a45ba1d0d4..1955927e2dfc34d5856ddbf869b0869386af398b 100644 --- a/spec/helpers/gitlab_routing_helper_spec.rb +++ b/spec/helpers/gitlab_routing_helper_spec.rb @@ -113,6 +113,29 @@ describe GitlabRoutingHelper do end end + context 'artifacts' do + let_it_be(:project) { create(:project) } + let_it_be(:job) { create(:ci_build, project: project, name: 'test:job', artifacts_expire_at: 1.hour.from_now) } + + describe '#fast_download_project_job_artifacts_path' do + it 'matches the Rails download path' do + expect(fast_download_project_job_artifacts_path(project, job)).to eq(download_project_job_artifacts_path(project, job)) + end + end + + describe '#fast_keep_project_job_artifacts_path' do + it 'matches the Rails keep path' do + expect(fast_keep_project_job_artifacts_path(project, job)).to eq(keep_project_job_artifacts_path(project, job)) + end + end + + describe '#fast_browse_project_job_artifacts_path' do + it 'matches the Rails browse path' do + expect(fast_browse_project_job_artifacts_path(project, job)).to eq(browse_project_job_artifacts_path(project, job)) + end + end + end + context 'snippets' do let_it_be(:personal_snippet) { create(:personal_snippet) } let_it_be(:project_snippet) { create(:project_snippet) } diff --git a/spec/helpers/markup_helper_spec.rb b/spec/helpers/markup_helper_spec.rb index fce0b5bd7e3771e5fb7abe92ee813fc4d7a45006..a775c69335e9273d0f514fb98762badc02213c8f 100644 --- a/spec/helpers/markup_helper_spec.rb +++ b/spec/helpers/markup_helper_spec.rb @@ -273,16 +273,19 @@ describe MarkupHelper do describe '#render_wiki_content' do let(:wiki) { double('WikiPage', path: "file.#{extension}") } + let(:wiki_repository) { double('Repository') } let(:context) do { pipeline: :wiki, project: project, project_wiki: wiki, - page_slug: 'nested/page', issuable_state_filter_enabled: true + page_slug: 'nested/page', issuable_state_filter_enabled: true, + repository: wiki_repository } end before do expect(wiki).to receive(:content).and_return('wiki content') expect(wiki).to receive(:slug).and_return('nested/page') + expect(wiki).to receive(:repository).and_return(wiki_repository) helper.instance_variable_set(:@project_wiki, wiki) end @@ -354,10 +357,10 @@ describe MarkupHelper do describe '#markup_unsafe' do subject { helper.markup_unsafe(file_name, text, context) } + let_it_be(:project_base) { create(:project, :repository) } + let_it_be(:context) { { project: project_base } } let(:file_name) { 'foo.bar' } let(:text) { 'Noël' } - let(:project_base) { build(:project, :repository) } - let(:context) { { project: project_base } } context 'when text is missing' do let(:text) { nil } @@ -380,12 +383,21 @@ describe MarkupHelper do context 'when renderer returns an error' do before do - allow(Banzai).to receive(:render).and_raise("An error") + allow(Banzai).to receive(:render).and_raise(StandardError, "An error") end it 'returns html (rendered by ActionView:TextHelper)' do is_expected.to eq('<p>Noël</p>') end + + it 'logs the error' do + expect(Gitlab::ErrorTracking).to receive(:track_exception).with( + instance_of(StandardError), + project_id: project.id, file_name: 'foo.md', context: context + ) + + subject + end end end @@ -410,7 +422,7 @@ describe MarkupHelper do end context 'when file has an unknown type' do - let(:file_name) { 'foo' } + let(:file_name) { 'foo.tex' } it 'returns html (rendered by Gitlab::OtherMarkup)' do expected_html = 'Noël' diff --git a/spec/helpers/projects/error_tracking_helper_spec.rb b/spec/helpers/projects/error_tracking_helper_spec.rb index 753144eef896971fa028837c547bf228531454e4..325ff32dd89afae15d6dcd7259d62f780d63702c 100644 --- a/spec/helpers/projects/error_tracking_helper_spec.rb +++ b/spec/helpers/projects/error_tracking_helper_spec.rb @@ -11,6 +11,8 @@ describe Projects::ErrorTrackingHelper do describe '#error_tracking_data' do let(:can_enable_error_tracking) { true } let(:setting_path) { project_settings_operations_path(project) } + let(:list_path) { project_error_tracking_index_path(project) } + let(:project_path) { project.full_path } let(:index_path) do project_error_tracking_index_path(project, format: :json) @@ -30,6 +32,8 @@ describe Projects::ErrorTrackingHelper do 'user-can-enable-error-tracking' => 'true', 'enable-error-tracking-link' => setting_path, 'error-tracking-enabled' => 'false', + 'list-path' => list_path, + 'project-path' => project_path, 'illustration-path' => match_asset_path('/assets/illustrations/cluster_popover.svg') ) end @@ -79,12 +83,26 @@ describe Projects::ErrorTrackingHelper do describe '#error_details_data' do let(:issue_id) { 1234 } let(:route_params) { [project.owner, project, issue_id, { format: :json }] } + let(:list_path) { project_error_tracking_index_path(project) } let(:details_path) { details_namespace_project_error_tracking_index_path(*route_params) } + let(:project_path) { project.full_path } let(:stack_trace_path) { stack_trace_namespace_project_error_tracking_index_path(*route_params) } let(:issues_path) { project_issues_path(project) } let(:result) { helper.error_details_data(project, issue_id) } + it 'returns the correct list path' do + expect(result['list-path']).to eq list_path + end + + it 'returns the correct issue id' do + expect(result['issue-id']).to eq issue_id + end + + it 'returns the correct project path' do + expect(result['project-path']).to eq project_path + end + it 'returns the correct details path' do expect(result['issue-details-path']).to eq details_path end diff --git a/spec/helpers/projects_helper_spec.rb b/spec/helpers/projects_helper_spec.rb index 46228d0d1c2c24abeab9160f23f6464ac945c31f..c7e454771bb49705177478a7a31d5583b1131bc8 100644 --- a/spec/helpers/projects_helper_spec.rb +++ b/spec/helpers/projects_helper_spec.rb @@ -332,13 +332,13 @@ describe ProjectsHelper do end it 'returns image tag for member avatar' do - expect(helper).to receive(:image_tag).with(expected, { width: 16, class: ["avatar", "avatar-inline", "s16"], alt: "", "data-src" => anything }) + expect(helper).to receive(:image_tag).with(expected, { width: 16, class: %w[avatar avatar-inline s16], alt: "", "data-src" => anything }) helper.link_to_member_avatar(user) end it 'returns image tag with avatar class' do - expect(helper).to receive(:image_tag).with(expected, { width: 16, class: ["avatar", "avatar-inline", "s16", "any-avatar-class"], alt: "", "data-src" => anything }) + expect(helper).to receive(:image_tag).with(expected, { width: 16, class: %w[avatar avatar-inline s16 any-avatar-class], alt: "", "data-src" => anything }) helper.link_to_member_avatar(user, avatar_class: "any-avatar-class") end diff --git a/spec/helpers/users_helper_spec.rb b/spec/helpers/users_helper_spec.rb index 172ead158fb56db340899b2c68f02fba372b4ecd..8479f8509f56af28c855860aa2ad10b2256eef3d 100644 --- a/spec/helpers/users_helper_spec.rb +++ b/spec/helpers/users_helper_spec.rb @@ -7,6 +7,10 @@ describe UsersHelper do let(:user) { create(:user) } + def filter_ee_badges(badges) + badges.reject { |badge| badge[:text] == 'Is using seat' } + end + describe '#user_link' do subject { helper.user_link(user) } @@ -118,7 +122,7 @@ describe UsersHelper do badges = helper.user_badges_in_admin_section(blocked_user) - expect(badges).to eq([text: "Blocked", variant: "danger"]) + expect(filter_ee_badges(badges)).to eq([text: "Blocked", variant: "danger"]) end end @@ -128,7 +132,7 @@ describe UsersHelper do badges = helper.user_badges_in_admin_section(admin_user) - expect(badges).to eq([text: "Admin", variant: "success"]) + expect(filter_ee_badges(badges)).to eq([text: "Admin", variant: "success"]) end end @@ -138,7 +142,7 @@ describe UsersHelper do badges = helper.user_badges_in_admin_section(external_user) - expect(badges).to eq([text: "External", variant: "secondary"]) + expect(filter_ee_badges(badges)).to eq([text: "External", variant: "secondary"]) end end @@ -146,7 +150,7 @@ describe UsersHelper do it 'returns the "It\'s You" badge' do badges = helper.user_badges_in_admin_section(user) - expect(badges).to eq([text: "It's you!", variant: nil]) + expect(filter_ee_badges(badges)).to eq([text: "It's you!", variant: nil]) end end @@ -170,7 +174,7 @@ describe UsersHelper do badges = helper.user_badges_in_admin_section(user) - expect(badges).to be_empty + expect(filter_ee_badges(badges)).to be_empty end end end diff --git a/spec/initializers/database_config_spec.rb b/spec/initializers/database_config_spec.rb index a5a074f5884de1feae74c33414db819fc5c5917c..85577ce007a54a425d1cc16cbdf19a70b1255f53 100644 --- a/spec/initializers/database_config_spec.rb +++ b/spec/initializers/database_config_spec.rb @@ -11,13 +11,12 @@ describe 'Database config initializer' do allow(ActiveRecord::Base).to receive(:establish_connection) end - context "when using Puma" do - let(:puma) { double('puma') } - let(:puma_options) { { max_threads: 8 } } + context "when using multi-threaded runtime" do + let(:max_threads) { 8 } before do - stub_const("Puma", puma) - allow(puma).to receive_message_chain(:cli_config, :options).and_return(puma_options) + allow(Gitlab::Runtime).to receive(:multi_threaded?).and_return(true) + allow(Gitlab::Runtime).to receive(:max_threads).and_return(max_threads) end context "and no existing pool size is set" do @@ -26,23 +25,23 @@ describe 'Database config initializer' do end it "sets it to the max number of worker threads" do - expect { subject }.to change { Gitlab::Database.config['pool'] }.from(nil).to(8) + expect { subject }.to change { Gitlab::Database.config['pool'] }.from(nil).to(max_threads) end end context "and the existing pool size is smaller than the max number of worker threads" do before do - stub_database_config(pool_size: 7) + stub_database_config(pool_size: max_threads - 1) end it "sets it to the max number of worker threads" do - expect { subject }.to change { Gitlab::Database.config['pool'] }.from(7).to(8) + expect { subject }.to change { Gitlab::Database.config['pool'] }.by(1) end end context "and the existing pool size is larger than the max number of worker threads" do before do - stub_database_config(pool_size: 9) + stub_database_config(pool_size: max_threads + 1) end it "keeps the configured pool size" do @@ -51,11 +50,7 @@ describe 'Database config initializer' do end end - context "when not using Puma" do - before do - stub_database_config(pool_size: 7) - end - + context "when using single-threaded runtime" do it "does nothing" do expect { subject }.not_to change { Gitlab::Database.config['pool'] } end diff --git a/spec/initializers/lograge_spec.rb b/spec/initializers/lograge_spec.rb index 5dd296b60405177eef62814228a784f5b628ceb3..65652468d93fa934e53923e3a9e69f47d66ae60f 100644 --- a/spec/initializers/lograge_spec.rb +++ b/spec/initializers/lograge_spec.rb @@ -112,7 +112,7 @@ describe 'lograge', type: :request do expect(log_data['exception.class']).to eq('RuntimeError') expect(log_data['exception.message']).to eq('bad request') - expect(log_data['exception.backtrace']).to eq(Gitlab::Profiler.clean_backtrace(backtrace)) + expect(log_data['exception.backtrace']).to eq(Gitlab::BacktraceCleaner.clean_backtrace(backtrace)) end end end diff --git a/spec/javascripts/behaviors/bind_in_out_spec.js b/spec/javascripts/behaviors/bind_in_out_spec.js deleted file mode 100644 index 0c214f5886aba271baf9f56f015caeaefe3791c5..0000000000000000000000000000000000000000 --- a/spec/javascripts/behaviors/bind_in_out_spec.js +++ /dev/null @@ -1,192 +0,0 @@ -import BindInOut from '~/behaviors/bind_in_out'; -import ClassSpecHelper from '../helpers/class_spec_helper'; - -describe('BindInOut', function() { - describe('constructor', function() { - beforeEach(function() { - this.in = {}; - this.out = {}; - - this.bindInOut = new BindInOut(this.in, this.out); - }); - - it('should set .in', function() { - expect(this.bindInOut.in).toBe(this.in); - }); - - it('should set .out', function() { - expect(this.bindInOut.out).toBe(this.out); - }); - - it('should set .eventWrapper', function() { - expect(this.bindInOut.eventWrapper).toEqual({}); - }); - - describe('if .in is an input', function() { - beforeEach(function() { - this.bindInOut = new BindInOut({ tagName: 'INPUT' }); - }); - - it('should set .eventType to keyup ', function() { - expect(this.bindInOut.eventType).toEqual('keyup'); - }); - }); - - describe('if .in is a textarea', function() { - beforeEach(function() { - this.bindInOut = new BindInOut({ tagName: 'TEXTAREA' }); - }); - - it('should set .eventType to keyup ', function() { - expect(this.bindInOut.eventType).toEqual('keyup'); - }); - }); - - describe('if .in is not an input or textarea', function() { - beforeEach(function() { - this.bindInOut = new BindInOut({ tagName: 'SELECT' }); - }); - - it('should set .eventType to change ', function() { - expect(this.bindInOut.eventType).toEqual('change'); - }); - }); - }); - - describe('addEvents', function() { - beforeEach(function() { - this.in = jasmine.createSpyObj('in', ['addEventListener']); - - this.bindInOut = new BindInOut(this.in); - - this.addEvents = this.bindInOut.addEvents(); - }); - - it('should set .eventWrapper.updateOut', function() { - expect(this.bindInOut.eventWrapper.updateOut).toEqual(jasmine.any(Function)); - }); - - it('should call .addEventListener', function() { - expect(this.in.addEventListener).toHaveBeenCalledWith( - this.bindInOut.eventType, - this.bindInOut.eventWrapper.updateOut, - ); - }); - - it('should return the instance', function() { - expect(this.addEvents).toBe(this.bindInOut); - }); - }); - - describe('updateOut', function() { - beforeEach(function() { - this.in = { value: 'the-value' }; - this.out = { textContent: 'not-the-value' }; - - this.bindInOut = new BindInOut(this.in, this.out); - - this.updateOut = this.bindInOut.updateOut(); - }); - - it('should set .out.textContent to .in.value', function() { - expect(this.out.textContent).toBe(this.in.value); - }); - - it('should return the instance', function() { - expect(this.updateOut).toBe(this.bindInOut); - }); - }); - - describe('removeEvents', function() { - beforeEach(function() { - this.in = jasmine.createSpyObj('in', ['removeEventListener']); - this.updateOut = () => {}; - - this.bindInOut = new BindInOut(this.in); - this.bindInOut.eventWrapper.updateOut = this.updateOut; - - this.removeEvents = this.bindInOut.removeEvents(); - }); - - it('should call .removeEventListener', function() { - expect(this.in.removeEventListener).toHaveBeenCalledWith( - this.bindInOut.eventType, - this.updateOut, - ); - }); - - it('should return the instance', function() { - expect(this.removeEvents).toBe(this.bindInOut); - }); - }); - - describe('initAll', function() { - beforeEach(function() { - this.ins = [0, 1, 2]; - this.instances = []; - - spyOn(document, 'querySelectorAll').and.returnValue(this.ins); - spyOn(Array.prototype, 'map').and.callThrough(); - spyOn(BindInOut, 'init'); - - this.initAll = BindInOut.initAll(); - }); - - ClassSpecHelper.itShouldBeAStaticMethod(BindInOut, 'initAll'); - - it('should call .querySelectorAll', function() { - expect(document.querySelectorAll).toHaveBeenCalledWith('*[data-bind-in]'); - }); - - it('should call .map', function() { - expect(Array.prototype.map).toHaveBeenCalledWith(jasmine.any(Function)); - }); - - it('should call .init for each element', function() { - expect(BindInOut.init.calls.count()).toEqual(3); - }); - - it('should return an array of instances', function() { - expect(this.initAll).toEqual(jasmine.any(Array)); - }); - }); - - describe('init', function() { - beforeEach(function() { - spyOn(BindInOut.prototype, 'addEvents').and.callFake(function() { - return this; - }); - spyOn(BindInOut.prototype, 'updateOut').and.callFake(function() { - return this; - }); - - this.init = BindInOut.init({}, {}); - }); - - ClassSpecHelper.itShouldBeAStaticMethod(BindInOut, 'init'); - - it('should call .addEvents', function() { - expect(BindInOut.prototype.addEvents).toHaveBeenCalled(); - }); - - it('should call .updateOut', function() { - expect(BindInOut.prototype.updateOut).toHaveBeenCalled(); - }); - - describe('if no anOut is provided', function() { - beforeEach(function() { - this.anIn = { dataset: { bindIn: 'the-data-bind-in' } }; - - spyOn(document, 'querySelector'); - - BindInOut.init(this.anIn); - }); - - it('should call .querySelector', function() { - expect(document.querySelector).toHaveBeenCalledWith( - `*[data-bind-out="${this.anIn.dataset.bindIn}"]`, - ); - }); - }); - }); -}); diff --git a/spec/javascripts/breakpoints_spec.js b/spec/javascripts/breakpoints_spec.js deleted file mode 100644 index fc0d9eb907a9471b70a666280be2862ed9823e32..0000000000000000000000000000000000000000 --- a/spec/javascripts/breakpoints_spec.js +++ /dev/null @@ -1,27 +0,0 @@ -import bp, { breakpoints } from '~/breakpoints'; - -describe('breakpoints', () => { - Object.keys(breakpoints).forEach(key => { - const size = breakpoints[key]; - - it(`returns ${key} when larger than ${size}`, () => { - spyOn(bp, 'windowWidth').and.returnValue(size + 10); - - expect(bp.getBreakpointSize()).toBe(key); - }); - }); - - describe('isDesktop', () => { - it('returns true when screen size is medium', () => { - spyOn(bp, 'windowWidth').and.returnValue(breakpoints.md + 10); - - expect(bp.isDesktop()).toBe(true); - }); - - it('returns false when screen size is small', () => { - spyOn(bp, 'windowWidth').and.returnValue(breakpoints.sm + 10); - - expect(bp.isDesktop()).toBe(false); - }); - }); -}); diff --git a/spec/javascripts/create_cluster/gke_cluster/components/gke_machine_type_dropdown_spec.js b/spec/javascripts/create_cluster/gke_cluster/components/gke_machine_type_dropdown_spec.js deleted file mode 100644 index e687040ddf93490904ba2b44201b6ce1e6a390c4..0000000000000000000000000000000000000000 --- a/spec/javascripts/create_cluster/gke_cluster/components/gke_machine_type_dropdown_spec.js +++ /dev/null @@ -1,109 +0,0 @@ -import Vue from 'vue'; -import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; -import GkeMachineTypeDropdown from '~/create_cluster/gke_cluster/components/gke_machine_type_dropdown.vue'; -import { createStore } from '~/create_cluster/gke_cluster/store'; -import { - SET_PROJECT, - SET_PROJECT_BILLING_STATUS, - SET_ZONE, - SET_MACHINE_TYPES, -} from '~/create_cluster/gke_cluster/store/mutation_types'; -import { - selectedZoneMock, - selectedProjectMock, - selectedMachineTypeMock, - gapiMachineTypesResponseMock, -} from '../mock_data'; - -const componentConfig = { - fieldId: 'cluster_provider_gcp_attributes_gcp_machine_type', - fieldName: 'cluster[provider_gcp_attributes][gcp_machine_type]', -}; - -const LABELS = { - LOADING: 'Fetching machine types', - DISABLED_NO_PROJECT: 'Select project and zone to choose machine type', - DISABLED_NO_ZONE: 'Select zone to choose machine type', - DEFAULT: 'Select machine type', -}; - -const createComponent = (store, props = componentConfig) => { - const Component = Vue.extend(GkeMachineTypeDropdown); - - return mountComponentWithStore(Component, { - el: null, - props, - store, - }); -}; - -describe('GkeMachineTypeDropdown', () => { - let vm; - let store; - - beforeEach(() => { - store = createStore(); - vm = createComponent(store); - }); - - afterEach(() => { - vm.$destroy(); - }); - - describe('shows various toggle text depending on state', () => { - it('returns disabled state toggle text when no project and zone are selected', () => { - expect(vm.toggleText).toBe(LABELS.DISABLED_NO_PROJECT); - }); - - it('returns disabled state toggle text when no zone is selected', () => { - vm.$store.commit(SET_PROJECT, selectedProjectMock); - vm.$store.commit(SET_PROJECT_BILLING_STATUS, true); - - expect(vm.toggleText).toBe(LABELS.DISABLED_NO_ZONE); - }); - - it('returns loading toggle text', () => { - vm.isLoading = true; - - expect(vm.toggleText).toBe(LABELS.LOADING); - }); - - it('returns default toggle text', () => { - expect(vm.toggleText).toBe(LABELS.DISABLED_NO_PROJECT); - - vm.$store.commit(SET_PROJECT, selectedProjectMock); - vm.$store.commit(SET_PROJECT_BILLING_STATUS, true); - vm.$store.commit(SET_ZONE, selectedZoneMock); - - expect(vm.toggleText).toBe(LABELS.DEFAULT); - }); - - it('returns machine type name if machine type selected', () => { - vm.setItem(selectedMachineTypeMock); - - expect(vm.toggleText).toBe(selectedMachineTypeMock); - }); - }); - - describe('form input', () => { - it('reflects new value when dropdown item is clicked', done => { - expect(vm.$el.querySelector('input').value).toBe(''); - vm.$store.commit(SET_MACHINE_TYPES, gapiMachineTypesResponseMock.items); - - return vm - .$nextTick() - .then(() => { - vm.$el.querySelector('.dropdown-content button').click(); - - return vm - .$nextTick() - .then(() => { - expect(vm.$el.querySelector('input').value).toBe(selectedMachineTypeMock); - done(); - }) - .catch(done.fail); - }) - .catch(done.fail); - }); - }); -}); diff --git a/spec/javascripts/create_cluster/gke_cluster/components/gke_project_id_dropdown_spec.js b/spec/javascripts/create_cluster/gke_cluster/components/gke_project_id_dropdown_spec.js deleted file mode 100644 index 4c89124454e5d9c402988da80031ab9df8e03c28..0000000000000000000000000000000000000000 --- a/spec/javascripts/create_cluster/gke_cluster/components/gke_project_id_dropdown_spec.js +++ /dev/null @@ -1,115 +0,0 @@ -import Vue from 'vue'; -import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; -import GkeProjectIdDropdown from '~/create_cluster/gke_cluster/components/gke_project_id_dropdown.vue'; -import { createStore } from '~/create_cluster/gke_cluster/store'; -import { SET_PROJECTS } from '~/create_cluster/gke_cluster/store/mutation_types'; -import { emptyProjectMock, selectedProjectMock } from '../mock_data'; -import { gapi } from '../helpers'; - -const componentConfig = { - docsUrl: 'https://console.cloud.google.com/home/dashboard', - fieldId: 'cluster_provider_gcp_attributes_gcp_project_id', - fieldName: 'cluster[provider_gcp_attributes][gcp_project_id]', -}; - -const LABELS = { - LOADING: 'Fetching projects', - VALIDATING_PROJECT_BILLING: 'Validating project billing status', - DEFAULT: 'Select project', - EMPTY: 'No projects found', -}; - -const createComponent = (store, props = componentConfig) => { - const Component = Vue.extend(GkeProjectIdDropdown); - - return mountComponentWithStore(Component, { - el: null, - props, - store, - }); -}; - -describe('GkeProjectIdDropdown', () => { - let vm; - let store; - - let originalGapi; - beforeAll(() => { - originalGapi = window.gapi; - window.gapi = gapi(); - }); - - afterAll(() => { - window.gapi = originalGapi; - }); - - beforeEach(() => { - store = createStore(); - vm = createComponent(store); - }); - - afterEach(() => { - vm.$destroy(); - }); - - describe('toggleText', () => { - it('returns loading toggle text', () => { - expect(vm.toggleText).toBe(LABELS.LOADING); - }); - - it('returns project billing validation text', () => { - vm.setIsValidatingProjectBilling(true); - - expect(vm.toggleText).toBe(LABELS.VALIDATING_PROJECT_BILLING); - }); - - it('returns default toggle text', done => - setTimeout(() => { - vm.setItem(emptyProjectMock); - - expect(vm.toggleText).toBe(LABELS.DEFAULT); - - done(); - })); - - it('returns project name if project selected', done => - setTimeout(() => { - vm.isLoading = false; - - expect(vm.toggleText).toBe(selectedProjectMock.name); - - done(); - })); - - it('returns empty toggle text', done => - setTimeout(() => { - vm.$store.commit(SET_PROJECTS, null); - vm.setItem(emptyProjectMock); - - expect(vm.toggleText).toBe(LABELS.EMPTY); - - done(); - })); - }); - - describe('selectItem', () => { - it('reflects new value when dropdown item is clicked', done => { - expect(vm.$el.querySelector('input').value).toBe(''); - - return vm - .$nextTick() - .then(() => { - vm.$el.querySelector('.dropdown-content button').click(); - - return vm - .$nextTick() - .then(() => { - expect(vm.$el.querySelector('input').value).toBe(selectedProjectMock.projectId); - done(); - }) - .catch(done.fail); - }) - .catch(done.fail); - }); - }); -}); diff --git a/spec/javascripts/diffs/components/app_spec.js b/spec/javascripts/diffs/components/app_spec.js index 48e1ed18a2f6279311861a6cc75d3fe8ffbe6ef9..5f97182489e0765115a347784ff4686529a12a2f 100644 --- a/spec/javascripts/diffs/components/app_spec.js +++ b/spec/javascripts/diffs/components/app_spec.js @@ -10,6 +10,7 @@ import CompareVersions from '~/diffs/components/compare_versions.vue'; import HiddenFilesWarning from '~/diffs/components/hidden_files_warning.vue'; import CommitWidget from '~/diffs/components/commit_widget.vue'; import TreeList from '~/diffs/components/tree_list.vue'; +import { INLINE_DIFF_VIEW_TYPE, PARALLEL_DIFF_VIEW_TYPE } from '~/diffs/constants'; import createDiffsStore from '../create_diffs_store'; import diffsMockData from '../mock_data/merge_request_diffs'; @@ -41,7 +42,6 @@ describe('diffs/components/app', () => { changesEmptyStateIllustration: '', dismissEndpoint: '', showSuggestPopover: true, - useSingleDiffStyle: false, ...props, }, store, @@ -53,6 +53,12 @@ describe('diffs/components/app', () => { }); } + function getOppositeViewType(currentViewType) { + return currentViewType === INLINE_DIFF_VIEW_TYPE + ? PARALLEL_DIFF_VIEW_TYPE + : INLINE_DIFF_VIEW_TYPE; + } + beforeEach(() => { // setup globals (needed for component to mount :/) window.mrTabs = jasmine.createSpyObj('mrTabs', ['resetViewContainer']); @@ -68,17 +74,164 @@ describe('diffs/components/app', () => { }); describe('fetch diff methods', () => { - beforeEach(() => { + beforeEach(done => { + const fetchResolver = () => { + store.state.diffs.retrievingBatches = false; + store.state.notes.discussions = 'test'; + return Promise.resolve({ real_size: 100 }); + }; spyOn(window, 'requestIdleCallback').and.callFake(fn => fn()); createComponent(); - spyOn(wrapper.vm, 'fetchDiffFiles').and.callFake(() => Promise.resolve()); - spyOn(wrapper.vm, 'fetchDiffFilesMeta').and.callFake(() => Promise.resolve()); - spyOn(wrapper.vm, 'fetchDiffFilesBatch').and.callFake(() => Promise.resolve()); + spyOn(wrapper.vm, 'fetchDiffFiles').and.callFake(fetchResolver); + spyOn(wrapper.vm, 'fetchDiffFilesMeta').and.callFake(fetchResolver); + spyOn(wrapper.vm, 'fetchDiffFilesBatch').and.callFake(fetchResolver); spyOn(wrapper.vm, 'setDiscussions'); spyOn(wrapper.vm, 'startRenderDiffsQueue'); + spyOn(wrapper.vm, 'unwatchDiscussions'); + spyOn(wrapper.vm, 'unwatchRetrievingBatches'); + store.state.diffs.retrievingBatches = true; + store.state.diffs.diffFiles = []; + wrapper.vm.$nextTick(done); + }); + + describe('when the diff view type changes and it should load a single diff view style', () => { + const noLinesDiff = { + highlighted_diff_lines: [], + parallel_diff_lines: [], + }; + const parallelLinesDiff = { + highlighted_diff_lines: [], + parallel_diff_lines: ['line'], + }; + const inlineLinesDiff = { + highlighted_diff_lines: ['line'], + parallel_diff_lines: [], + }; + const fullDiff = { + highlighted_diff_lines: ['line'], + parallel_diff_lines: ['line'], + }; + + function expectFetchToOccur({ + vueInstance, + done = () => {}, + batch = false, + existingFiles = 1, + } = {}) { + vueInstance.$nextTick(() => { + expect(vueInstance.diffFiles.length).toEqual(existingFiles); + + if (!batch) { + expect(vueInstance.fetchDiffFiles).toHaveBeenCalled(); + expect(vueInstance.fetchDiffFilesBatch).not.toHaveBeenCalled(); + } else { + expect(vueInstance.fetchDiffFiles).not.toHaveBeenCalled(); + expect(vueInstance.fetchDiffFilesBatch).toHaveBeenCalled(); + } + + done(); + }); + } + + beforeEach(() => { + wrapper.vm.glFeatures.singleMrDiffView = true; + }); + + it('fetches diffs if it has none', done => { + wrapper.vm.isLatestVersion = () => false; + + store.state.diffs.diffViewType = getOppositeViewType(wrapper.vm.diffViewType); + + expectFetchToOccur({ vueInstance: wrapper.vm, batch: false, existingFiles: 0, done }); + }); + + it('fetches diffs if it has both view styles, but no lines in either', done => { + wrapper.vm.isLatestVersion = () => false; + + store.state.diffs.diffFiles.push(noLinesDiff); + store.state.diffs.diffViewType = getOppositeViewType(wrapper.vm.diffViewType); + + expectFetchToOccur({ vueInstance: wrapper.vm, done }); + }); + + it('fetches diffs if it only has inline view style', done => { + wrapper.vm.isLatestVersion = () => false; + + store.state.diffs.diffFiles.push(inlineLinesDiff); + store.state.diffs.diffViewType = getOppositeViewType(wrapper.vm.diffViewType); + + expectFetchToOccur({ vueInstance: wrapper.vm, done }); + }); + + it('fetches diffs if it only has parallel view style', done => { + wrapper.vm.isLatestVersion = () => false; + + store.state.diffs.diffFiles.push(parallelLinesDiff); + store.state.diffs.diffViewType = getOppositeViewType(wrapper.vm.diffViewType); + + expectFetchToOccur({ vueInstance: wrapper.vm, done }); + }); + + it('fetches batch diffs if it has none', done => { + wrapper.vm.glFeatures.diffsBatchLoad = true; + + store.state.diffs.diffViewType = getOppositeViewType(wrapper.vm.diffViewType); + + expectFetchToOccur({ vueInstance: wrapper.vm, batch: true, existingFiles: 0, done }); + }); + + it('fetches batch diffs if it has both view styles, but no lines in either', done => { + wrapper.vm.glFeatures.diffsBatchLoad = true; + + store.state.diffs.diffFiles.push(noLinesDiff); + store.state.diffs.diffViewType = getOppositeViewType(wrapper.vm.diffViewType); + + expectFetchToOccur({ vueInstance: wrapper.vm, batch: true, done }); + }); + + it('fetches batch diffs if it only has inline view style', done => { + wrapper.vm.glFeatures.diffsBatchLoad = true; + + store.state.diffs.diffFiles.push(inlineLinesDiff); + store.state.diffs.diffViewType = getOppositeViewType(wrapper.vm.diffViewType); + + expectFetchToOccur({ vueInstance: wrapper.vm, batch: true, done }); + }); + + it('fetches batch diffs if it only has parallel view style', done => { + wrapper.vm.glFeatures.diffsBatchLoad = true; + + store.state.diffs.diffFiles.push(parallelLinesDiff); + store.state.diffs.diffViewType = getOppositeViewType(wrapper.vm.diffViewType); + + expectFetchToOccur({ vueInstance: wrapper.vm, batch: true, done }); + }); + + it('does not fetch diffs if it has already fetched both styles of diff', () => { + wrapper.vm.glFeatures.diffsBatchLoad = false; + + store.state.diffs.diffFiles.push(fullDiff); + store.state.diffs.diffViewType = getOppositeViewType(wrapper.vm.diffViewType); + + expect(wrapper.vm.diffFiles.length).toEqual(1); + expect(wrapper.vm.fetchDiffFiles).not.toHaveBeenCalled(); + expect(wrapper.vm.fetchDiffFilesBatch).not.toHaveBeenCalled(); + }); + + it('does not fetch batch diffs if it has already fetched both styles of diff', () => { + wrapper.vm.glFeatures.diffsBatchLoad = true; + + store.state.diffs.diffFiles.push(fullDiff); + store.state.diffs.diffViewType = getOppositeViewType(wrapper.vm.diffViewType); + + expect(wrapper.vm.diffFiles.length).toEqual(1); + expect(wrapper.vm.fetchDiffFiles).not.toHaveBeenCalled(); + expect(wrapper.vm.fetchDiffFilesBatch).not.toHaveBeenCalled(); + }); }); it('calls fetchDiffFiles if diffsBatchLoad is not enabled', done => { + expect(wrapper.vm.diffFilesLength).toEqual(0); wrapper.vm.glFeatures.diffsBatchLoad = false; wrapper.vm.fetchData(false); @@ -87,33 +240,46 @@ describe('diffs/components/app', () => { expect(wrapper.vm.startRenderDiffsQueue).toHaveBeenCalled(); expect(wrapper.vm.fetchDiffFilesMeta).not.toHaveBeenCalled(); expect(wrapper.vm.fetchDiffFilesBatch).not.toHaveBeenCalled(); + expect(wrapper.vm.unwatchDiscussions).toHaveBeenCalled(); + expect(wrapper.vm.diffFilesLength).toEqual(100); + expect(wrapper.vm.unwatchRetrievingBatches).toHaveBeenCalled(); done(); }); }); - it('calls batch methods if diffsBatchLoad is enabled, and not latest version', () => { + it('calls batch methods if diffsBatchLoad is enabled, and not latest version', done => { + expect(wrapper.vm.diffFilesLength).toEqual(0); wrapper.vm.glFeatures.diffsBatchLoad = true; wrapper.vm.isLatestVersion = () => false; wrapper.vm.fetchData(false); expect(wrapper.vm.fetchDiffFiles).not.toHaveBeenCalled(); - wrapper.vm.$nextTick(() => { + setTimeout(() => { expect(wrapper.vm.startRenderDiffsQueue).toHaveBeenCalled(); expect(wrapper.vm.fetchDiffFilesMeta).toHaveBeenCalled(); expect(wrapper.vm.fetchDiffFilesBatch).toHaveBeenCalled(); + expect(wrapper.vm.unwatchDiscussions).toHaveBeenCalled(); + expect(wrapper.vm.diffFilesLength).toEqual(100); + expect(wrapper.vm.unwatchRetrievingBatches).toHaveBeenCalled(); + done(); }); }); - it('calls batch methods if diffsBatchLoad is enabled, and latest version', () => { + it('calls batch methods if diffsBatchLoad is enabled, and latest version', done => { + expect(wrapper.vm.diffFilesLength).toEqual(0); wrapper.vm.glFeatures.diffsBatchLoad = true; wrapper.vm.fetchData(false); expect(wrapper.vm.fetchDiffFiles).not.toHaveBeenCalled(); - wrapper.vm.$nextTick(() => { + setTimeout(() => { expect(wrapper.vm.startRenderDiffsQueue).toHaveBeenCalled(); expect(wrapper.vm.fetchDiffFilesMeta).toHaveBeenCalled(); expect(wrapper.vm.fetchDiffFilesBatch).toHaveBeenCalled(); + expect(wrapper.vm.unwatchDiscussions).toHaveBeenCalled(); + expect(wrapper.vm.diffFilesLength).toEqual(100); + expect(wrapper.vm.unwatchRetrievingBatches).toHaveBeenCalled(); + done(); }); }); }); diff --git a/spec/javascripts/diffs/components/compare_versions_dropdown_spec.js b/spec/javascripts/diffs/components/compare_versions_dropdown_spec.js index 8a3834d542fa097ee9c00e415c1cbb86a14bdf28..df160d7a36383e8e6c42c6e4c6efe8a3b755b85f 100644 --- a/spec/javascripts/diffs/components/compare_versions_dropdown_spec.js +++ b/spec/javascripts/diffs/components/compare_versions_dropdown_spec.js @@ -25,7 +25,6 @@ describe('CompareVersionsDropdown', () => { const createComponent = (props = {}) => { wrapper = shallowMount(localVue.extend(CompareVersionsDropdown), { localVue, - sync: false, propsData: { ...props }, }); }; diff --git a/spec/javascripts/diffs/components/diff_discussions_spec.js b/spec/javascripts/diffs/components/diff_discussions_spec.js index f7f0ab83c212a5474d6f0396576adf0fa25e3e75..1b924bb947dd480aec968b8b778db64d132705fc 100644 --- a/spec/javascripts/diffs/components/diff_discussions_spec.js +++ b/spec/javascripts/diffs/components/diff_discussions_spec.js @@ -24,7 +24,6 @@ describe('DiffDiscussions', () => { ...props, }, localVue, - sync: false, }); }; diff --git a/spec/javascripts/diffs/store/actions_spec.js b/spec/javascripts/diffs/store/actions_spec.js index b23334d38dc2cad739eddf8067159c558a9b66de..af2dd7b4f93c2aba488fed29d0ce6bb17bfe76b9 100644 --- a/spec/javascripts/diffs/store/actions_spec.js +++ b/spec/javascripts/diffs/store/actions_spec.js @@ -120,7 +120,7 @@ describe('DiffsStoreActions', () => { describe('fetchDiffFiles', () => { it('should fetch diff files', done => { - const endpoint = '/fetch/diff/files?w=1'; + const endpoint = '/fetch/diff/files?view=inline&w=1'; const mock = new MockAdapter(axios); const res = { diff_files: 1, merge_request_diffs: [] }; mock.onGet(endpoint).reply(200, res); @@ -128,7 +128,7 @@ describe('DiffsStoreActions', () => { testAction( fetchDiffFiles, {}, - { endpoint }, + { endpoint, diffFiles: [], showWhitespace: false, diffViewType: 'inline' }, [ { type: types.SET_LOADING, payload: true }, { type: types.SET_LOADING, payload: false }, @@ -141,6 +141,13 @@ describe('DiffsStoreActions', () => { done(); }, ); + + fetchDiffFiles({ state: { endpoint }, commit: () => null }) + .then(data => { + expect(data).toEqual(res); + done(); + }) + .catch(done.fail); }); }); @@ -163,10 +170,12 @@ describe('DiffsStoreActions', () => { { endpointBatch }, [ { type: types.SET_BATCH_LOADING, payload: true }, + { type: types.SET_RETRIEVING_BATCHES, payload: true }, { type: types.SET_DIFF_DATA_BATCH, payload: { diff_files: res1.diff_files } }, { type: types.SET_BATCH_LOADING, payload: false }, { type: types.SET_DIFF_DATA_BATCH, payload: { diff_files: [] } }, { type: types.SET_BATCH_LOADING, payload: false }, + { type: types.SET_RETRIEVING_BATCHES, payload: false }, ], [], () => { @@ -215,6 +224,8 @@ describe('DiffsStoreActions', () => { describe('assignDiscussionsToDiff', () => { it('should merge discussions into diffs', done => { + window.location.hash = 'ABC_123'; + const state = { diffFiles: [ { diff --git a/spec/javascripts/diffs/store/getters_spec.js b/spec/javascripts/diffs/store/getters_spec.js index eab5703dfb27475639e0875b291835b0db8e7058..9e628fdd540e1df5cc24e6d42a556bb06a693bf6 100644 --- a/spec/javascripts/diffs/store/getters_spec.js +++ b/spec/javascripts/diffs/store/getters_spec.js @@ -263,14 +263,6 @@ describe('Diffs Module Getters', () => { }); }); - describe('diffFilesLength', () => { - it('returns length of diff files', () => { - localState.diffFiles.push('test', 'test 2'); - - expect(getters.diffFilesLength(localState)).toBe(2); - }); - }); - describe('currentDiffIndex', () => { it('returns index of currently selected diff in diffList', () => { localState.diffFiles = [{ file_hash: '111' }, { file_hash: '222' }, { file_hash: '333' }]; diff --git a/spec/javascripts/diffs/store/mutations_spec.js b/spec/javascripts/diffs/store/mutations_spec.js index 13f16e4f9a6e7a313044c405b1992850d86c629a..24405dcc7968cd810fd6f3908bcd5c0b61abc7de 100644 --- a/spec/javascripts/diffs/store/mutations_spec.js +++ b/spec/javascripts/diffs/store/mutations_spec.js @@ -40,9 +40,26 @@ describe('DiffsStoreMutations', () => { }); }); + describe('SET_RETRIEVING_BATCHES', () => { + it('should set retrievingBatches state', () => { + const state = {}; + + mutations[types.SET_RETRIEVING_BATCHES](state, false); + + expect(state.retrievingBatches).toEqual(false); + }); + }); + describe('SET_DIFF_DATA', () => { it('should set diff data type properly', () => { - const state = {}; + const state = { + diffFiles: [ + { + content_sha: diffFileMockData.content_sha, + file_hash: diffFileMockData.file_hash, + }, + ], + }; const diffMock = { diff_files: [diffFileMockData], }; @@ -52,9 +69,41 @@ describe('DiffsStoreMutations', () => { const firstLine = state.diffFiles[0].parallel_diff_lines[0]; expect(firstLine.right.text).toBeUndefined(); + expect(state.diffFiles.length).toEqual(1); expect(state.diffFiles[0].renderIt).toEqual(true); expect(state.diffFiles[0].collapsed).toEqual(false); }); + + describe('given diffsBatchLoad feature flag is enabled', () => { + beforeEach(() => { + gon.features = { diffsBatchLoad: true }; + }); + + afterEach(() => { + delete gon.features; + }); + + it('should not modify the existing state', () => { + const state = { + diffFiles: [ + { + content_sha: diffFileMockData.content_sha, + file_hash: diffFileMockData.file_hash, + highlighted_diff_lines: [], + }, + ], + }; + const diffMock = { + diff_files: [diffFileMockData], + }; + + mutations[types.SET_DIFF_DATA](state, diffMock); + + // If the batch load is enabled, there shouldn't be any processing + // done on the existing state object, so we shouldn't have this. + expect(state.diffFiles[0].parallel_diff_lines).toBeUndefined(); + }); + }); }); describe('SET_DIFFSET_DIFF_DATA_BATCH_DATA', () => { @@ -158,11 +207,17 @@ describe('DiffsStoreMutations', () => { it('should update the state with the given data for the given file hash', () => { const fileHash = 123; const state = { - diffFiles: [{}, { file_hash: fileHash, existing_field: 0 }], + diffFiles: [{}, { content_sha: 'abc', file_hash: fileHash, existing_field: 0 }], }; const data = { diff_files: [ - { file_hash: fileHash, extra_field: 1, existing_field: 1, viewer: { name: 'text' } }, + { + content_sha: 'abc', + file_hash: fileHash, + extra_field: 1, + existing_field: 1, + viewer: { name: 'text' }, + }, ], }; @@ -198,7 +253,7 @@ describe('DiffsStoreMutations', () => { discussions: [], }, right: { - line_code: 'ABC_1', + line_code: 'ABC_2', discussions: [], }, }, @@ -264,7 +319,7 @@ describe('DiffsStoreMutations', () => { discussions: [], }, right: { - line_code: 'ABC_1', + line_code: 'ABC_2', discussions: [], }, }, @@ -342,7 +397,7 @@ describe('DiffsStoreMutations', () => { discussions: [], }, right: { - line_code: 'ABC_1', + line_code: 'ABC_2', discussions: [], }, }, @@ -438,6 +493,7 @@ describe('DiffsStoreMutations', () => { discussions: [], }, ], + parallel_diff_lines: [], }, ], }; diff --git a/spec/javascripts/diffs/store/utils_spec.js b/spec/javascripts/diffs/store/utils_spec.js index 65eb4c9d2a3ce4a7689c3ab0bf345e5b04c2ac5f..638b4510221692c320fe3e64a2e599f76dbe19b6 100644 --- a/spec/javascripts/diffs/store/utils_spec.js +++ b/spec/javascripts/diffs/store/utils_spec.js @@ -314,11 +314,29 @@ describe('DiffsStoreUtils', () => { }); describe('prepareDiffData', () => { + let mock; let preparedDiff; + let splitInlineDiff; + let splitParallelDiff; + let completedDiff; beforeEach(() => { - preparedDiff = { diff_files: [getDiffFileMock()] }; + mock = getDiffFileMock(); + preparedDiff = { diff_files: [mock] }; + splitInlineDiff = { + diff_files: [Object.assign({}, mock, { parallel_diff_lines: undefined })], + }; + splitParallelDiff = { + diff_files: [Object.assign({}, mock, { highlighted_diff_lines: undefined })], + }; + completedDiff = { + diff_files: [Object.assign({}, mock, { highlighted_diff_lines: undefined })], + }; + utils.prepareDiffData(preparedDiff); + utils.prepareDiffData(splitInlineDiff); + utils.prepareDiffData(splitParallelDiff); + utils.prepareDiffData(completedDiff, [mock]); }); it('sets the renderIt and collapsed attribute on files', () => { @@ -359,6 +377,19 @@ describe('DiffsStoreUtils', () => { expect(firstLine.line_code).toEqual(firstLine.right.line_code); }); + + it('guarantees an empty array for both diff styles', () => { + expect(splitInlineDiff.diff_files[0].parallel_diff_lines.length).toEqual(0); + expect(splitInlineDiff.diff_files[0].highlighted_diff_lines.length).toBeGreaterThan(0); + expect(splitParallelDiff.diff_files[0].parallel_diff_lines.length).toBeGreaterThan(0); + expect(splitParallelDiff.diff_files[0].highlighted_diff_lines.length).toEqual(0); + }); + + it('merges existing diff files with newly loaded diff files to ensure split diffs are eventually completed', () => { + expect(completedDiff.diff_files.length).toEqual(1); + expect(completedDiff.diff_files[0].parallel_diff_lines.length).toBeGreaterThan(0); + expect(completedDiff.diff_files[0].highlighted_diff_lines.length).toBeGreaterThan(0); + }); }); describe('isDiscussionApplicableToLine', () => { diff --git a/spec/javascripts/droplab/constants_spec.js b/spec/javascripts/droplab/constants_spec.js deleted file mode 100644 index 23b69defec617eb4cb8986ce8658fa1271324c12..0000000000000000000000000000000000000000 --- a/spec/javascripts/droplab/constants_spec.js +++ /dev/null @@ -1,39 +0,0 @@ -import * as constants from '~/droplab/constants'; - -describe('constants', function() { - describe('DATA_TRIGGER', function() { - it('should be `data-dropdown-trigger`', function() { - expect(constants.DATA_TRIGGER).toBe('data-dropdown-trigger'); - }); - }); - - describe('DATA_DROPDOWN', function() { - it('should be `data-dropdown`', function() { - expect(constants.DATA_DROPDOWN).toBe('data-dropdown'); - }); - }); - - describe('SELECTED_CLASS', function() { - it('should be `droplab-item-selected`', function() { - expect(constants.SELECTED_CLASS).toBe('droplab-item-selected'); - }); - }); - - describe('ACTIVE_CLASS', function() { - it('should be `droplab-item-active`', function() { - expect(constants.ACTIVE_CLASS).toBe('droplab-item-active'); - }); - }); - - describe('TEMPLATE_REGEX', function() { - it('should be a handlebars templating syntax regex', function() { - expect(constants.TEMPLATE_REGEX).toEqual(/\{\{(.+?)\}\}/g); - }); - }); - - describe('IGNORE_CLASS', function() { - it('should be `droplab-item-ignore`', function() { - expect(constants.IGNORE_CLASS).toBe('droplab-item-ignore'); - }); - }); -}); diff --git a/spec/javascripts/droplab/drop_down_spec.js b/spec/javascripts/droplab/drop_down_spec.js index 18ab03653f485dec67ad8d18ad8f5806d3cd301d..22346c1054775a02fc6921c4c65cca6a5eceda10 100644 --- a/spec/javascripts/droplab/drop_down_spec.js +++ b/spec/javascripts/droplab/drop_down_spec.js @@ -398,14 +398,21 @@ describe('DropLab DropDown', function() { describe('render', function() { beforeEach(function() { - this.list = { querySelector: () => {}, dispatchEvent: () => {} }; - this.dropdown = { renderChildren: () => {}, list: this.list }; this.renderableList = {}; + this.list = { + querySelector: q => { + if (q === '.filter-dropdown-loading') { + return false; + } + return this.renderableList; + }, + dispatchEvent: () => {}, + }; + this.dropdown = { renderChildren: () => {}, list: this.list }; this.data = [0, 1]; this.customEvent = {}; spyOn(this.dropdown, 'renderChildren').and.callFake(data => data); - spyOn(this.list, 'querySelector').and.returnValue(this.renderableList); spyOn(this.list, 'dispatchEvent'); spyOn(this.data, 'map').and.callThrough(); spyOn(window, 'CustomEvent').and.returnValue(this.customEvent); diff --git a/spec/javascripts/dropzone_input_spec.js b/spec/javascripts/dropzone_input_spec.js index 8d0f0d20d893d64e8c8910ef2054cb9a741deacf..6f6f20ccca258f1d7169a456dfbd11e14f7722dc 100644 --- a/spec/javascripts/dropzone_input_spec.js +++ b/spec/javascripts/dropzone_input_spec.js @@ -1,6 +1,7 @@ import $ from 'jquery'; import { TEST_HOST } from 'spec/test_constants'; import dropzoneInput from '~/dropzone_input'; +import PasteMarkdownTable from '~/behaviors/markdown/paste_markdown_table'; const TEST_FILE = new File([], 'somefile.jpg'); TEST_FILE.upload = {}; @@ -25,6 +26,34 @@ describe('dropzone_input', () => { expect(dropzone.version).toBeTruthy(); }); + describe('handlePaste', () => { + beforeEach(() => { + loadFixtures('issues/new-issue.html'); + + const form = $('#new_issue'); + form.data('uploads-path', TEST_UPLOAD_PATH); + dropzoneInput(form); + }); + + it('pastes Markdown tables', () => { + const event = $.Event('paste'); + const origEvent = new Event('paste'); + const pasteData = new DataTransfer(); + pasteData.setData('text/plain', 'Hello World'); + pasteData.setData('text/html', '<table><tr><td>Hello World</td></tr></table>'); + origEvent.clipboardData = pasteData; + event.originalEvent = origEvent; + + spyOn(PasteMarkdownTable.prototype, 'isTable').and.callThrough(); + spyOn(PasteMarkdownTable.prototype, 'convertToTableMarkdown').and.callThrough(); + + $('.js-gfm-input').trigger(event); + + expect(PasteMarkdownTable.prototype.isTable).toHaveBeenCalled(); + expect(PasteMarkdownTable.prototype.convertToTableMarkdown).toHaveBeenCalled(); + }); + }); + describe('shows error message', () => { let form; let dropzone; diff --git a/spec/javascripts/filtered_search/dropdown_utils_spec.js b/spec/javascripts/filtered_search/dropdown_utils_spec.js index 62d1bd6963576b54ded908a0075fefc3292e92dd..6eda4f391a488e9104fe36c850bb22e2ad892be8 100644 --- a/spec/javascripts/filtered_search/dropdown_utils_spec.js +++ b/spec/javascripts/filtered_search/dropdown_utils_spec.js @@ -222,7 +222,7 @@ describe('Dropdown Utils', () => { hasAttribute: () => false, }; - DropdownUtils.setDataValueIfSelected(null, selected); + DropdownUtils.setDataValueIfSelected(null, '=', selected); expect(FilteredSearchDropdownManager.addWordToInput.calls.count()).toEqual(1); }); @@ -233,9 +233,11 @@ describe('Dropdown Utils', () => { hasAttribute: () => false, }; - const result = DropdownUtils.setDataValueIfSelected(null, selected); + const result = DropdownUtils.setDataValueIfSelected(null, '=', selected); + const result2 = DropdownUtils.setDataValueIfSelected(null, '!=', selected); expect(result).toBe(true); + expect(result2).toBe(true); }); it('returns false when dataValue does not exist', () => { @@ -243,9 +245,11 @@ describe('Dropdown Utils', () => { getAttribute: () => null, }; - const result = DropdownUtils.setDataValueIfSelected(null, selected); + const result = DropdownUtils.setDataValueIfSelected(null, '=', selected); + const result2 = DropdownUtils.setDataValueIfSelected(null, '!=', selected); expect(result).toBe(false); + expect(result2).toBe(false); }); }); @@ -349,7 +353,7 @@ describe('Dropdown Utils', () => { beforeEach(() => { loadFixtures(issueListFixture); - authorToken = FilteredSearchSpecHelper.createFilterVisualToken('author', '@user'); + authorToken = FilteredSearchSpecHelper.createFilterVisualToken('author', '=', '@user'); const searchTermToken = FilteredSearchSpecHelper.createSearchVisualToken('search term'); const tokensContainer = document.querySelector('.tokens-container'); @@ -364,7 +368,7 @@ describe('Dropdown Utils', () => { const searchQuery = DropdownUtils.getSearchQuery(); - expect(searchQuery).toBe(' search term author:original dance'); + expect(searchQuery).toBe(' search term author:=original dance'); }); }); }); diff --git a/spec/javascripts/filtered_search/filtered_search_dropdown_manager_spec.js b/spec/javascripts/filtered_search/filtered_search_dropdown_manager_spec.js index 8c5a0961a0253f908d46a3d10c05857c5d73efde..853f6b3b7b82338d2e31bdef07354ffb5499d50e 100644 --- a/spec/javascripts/filtered_search/filtered_search_dropdown_manager_spec.js +++ b/spec/javascripts/filtered_search/filtered_search_dropdown_manager_spec.js @@ -27,7 +27,7 @@ describe('Filtered Search Dropdown Manager', () => { describe('input has no existing value', () => { it('should add just tokenName', () => { - FilteredSearchDropdownManager.addWordToInput('milestone'); + FilteredSearchDropdownManager.addWordToInput({ tokenName: 'milestone' }); const token = document.querySelector('.tokens-container .js-visual-token'); @@ -36,8 +36,8 @@ describe('Filtered Search Dropdown Manager', () => { expect(getInputValue()).toBe(''); }); - it('should add tokenName and tokenValue', () => { - FilteredSearchDropdownManager.addWordToInput('label'); + it('should add tokenName, tokenOperator, and tokenValue', () => { + FilteredSearchDropdownManager.addWordToInput({ tokenName: 'label' }); let token = document.querySelector('.tokens-container .js-visual-token'); @@ -45,13 +45,27 @@ describe('Filtered Search Dropdown Manager', () => { expect(token.querySelector('.name').innerText).toBe('label'); expect(getInputValue()).toBe(''); - FilteredSearchDropdownManager.addWordToInput('label', 'none'); + FilteredSearchDropdownManager.addWordToInput({ tokenName: 'label', tokenOperator: '=' }); + + token = document.querySelector('.tokens-container .js-visual-token'); + + expect(token.classList.contains('filtered-search-token')).toEqual(true); + expect(token.querySelector('.name').innerText).toBe('label'); + expect(token.querySelector('.operator').innerText).toBe('='); + expect(getInputValue()).toBe(''); + + FilteredSearchDropdownManager.addWordToInput({ + tokenName: 'label', + tokenOperator: '=', + tokenValue: 'none', + }); // We have to get that reference again // Because FilteredSearchDropdownManager deletes the previous token token = document.querySelector('.tokens-container .js-visual-token'); expect(token.classList.contains('filtered-search-token')).toEqual(true); expect(token.querySelector('.name').innerText).toBe('label'); + expect(token.querySelector('.operator').innerText).toBe('='); expect(token.querySelector('.value').innerText).toBe('none'); expect(getInputValue()).toBe(''); }); @@ -60,7 +74,7 @@ describe('Filtered Search Dropdown Manager', () => { describe('input has existing value', () => { it('should be able to just add tokenName', () => { setInputValue('a'); - FilteredSearchDropdownManager.addWordToInput('author'); + FilteredSearchDropdownManager.addWordToInput({ tokenName: 'author' }); const token = document.querySelector('.tokens-container .js-visual-token'); @@ -70,29 +84,40 @@ describe('Filtered Search Dropdown Manager', () => { }); it('should replace tokenValue', () => { - FilteredSearchDropdownManager.addWordToInput('author'); + FilteredSearchDropdownManager.addWordToInput({ tokenName: 'author' }); + FilteredSearchDropdownManager.addWordToInput({ tokenName: 'author', tokenOperator: '=' }); setInputValue('roo'); - FilteredSearchDropdownManager.addWordToInput(null, '@root'); + FilteredSearchDropdownManager.addWordToInput({ + tokenName: null, + tokenOperator: '=', + tokenValue: '@root', + }); const token = document.querySelector('.tokens-container .js-visual-token'); expect(token.classList.contains('filtered-search-token')).toEqual(true); expect(token.querySelector('.name').innerText).toBe('author'); + expect(token.querySelector('.operator').innerText).toBe('='); expect(token.querySelector('.value').innerText).toBe('@root'); expect(getInputValue()).toBe(''); }); it('should add tokenValues containing spaces', () => { - FilteredSearchDropdownManager.addWordToInput('label'); + FilteredSearchDropdownManager.addWordToInput({ tokenName: 'label' }); setInputValue('"test '); - FilteredSearchDropdownManager.addWordToInput('label', '~\'"test me"\''); + FilteredSearchDropdownManager.addWordToInput({ + tokenName: 'label', + tokenOperator: '=', + tokenValue: '~\'"test me"\'', + }); const token = document.querySelector('.tokens-container .js-visual-token'); expect(token.classList.contains('filtered-search-token')).toEqual(true); expect(token.querySelector('.name').innerText).toBe('label'); + expect(token.querySelector('.operator').innerText).toBe('='); expect(token.querySelector('.value').innerText).toBe('~\'"test me"\''); expect(getInputValue()).toBe(''); }); diff --git a/spec/javascripts/filtered_search/filtered_search_manager_spec.js b/spec/javascripts/filtered_search/filtered_search_manager_spec.js index e076120f5ccce6d3878982ccf9347cbad69565d9..e5d1d1d690e4e87d51185a7575c9216a7f4d86e4 100644 --- a/spec/javascripts/filtered_search/filtered_search_manager_spec.js +++ b/spec/javascripts/filtered_search/filtered_search_manager_spec.js @@ -201,8 +201,8 @@ describe('Filtered Search Manager', function() { it('removes duplicated tokens', done => { tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(` - ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~bug')} - ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~bug')} + ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '=', '~bug')} + ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '=', '~bug')} `); spyOnDependency(FilteredSearchManager, 'visitUrl').and.callFake(url => { @@ -234,7 +234,7 @@ describe('Filtered Search Manager', function() { it('should not render placeholder when there are tokens and no input', () => { tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( - FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~bug'), + FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '=', '~bug'), ); const event = new Event('input'); @@ -252,7 +252,7 @@ describe('Filtered Search Manager', function() { describe('tokens and no input', () => { beforeEach(() => { tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( - FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~bug'), + FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '=', '~bug'), ); }); @@ -306,7 +306,7 @@ describe('Filtered Search Manager', function() { it('removes token even when it is already selected', () => { tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( - FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', 'none', true), + FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', '=', 'none', true), ); tokensContainer.querySelector('.js-visual-token .remove-token').click(); @@ -319,7 +319,7 @@ describe('Filtered Search Manager', function() { spyOn(FilteredSearchManager.prototype, 'removeSelectedToken').and.callThrough(); tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( - FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', 'none'), + FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', '=', 'none'), ); tokensContainer.querySelector('.js-visual-token .remove-token').click(); }); @@ -338,7 +338,7 @@ describe('Filtered Search Manager', function() { beforeEach(() => { initializeManager(); tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( - FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', 'none', true), + FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', '=', 'none', true), ); }); @@ -424,7 +424,7 @@ describe('Filtered Search Manager', function() { }); it('Clicking the "x" clear button, clears the input', () => { - const inputValue = 'label:~bug '; + const inputValue = 'label:=~bug'; manager.filteredSearchInput.value = inputValue; manager.filteredSearchInput.dispatchEvent(new Event('input')); diff --git a/spec/javascripts/filtered_search/filtered_search_visual_tokens_spec.js b/spec/javascripts/filtered_search/filtered_search_visual_tokens_spec.js index 0ee13faf8415072ac69769b5c36f0ec94fa64056..fda078bd41cbbec77566839b88c5f78ed7dbe68a 100644 --- a/spec/javascripts/filtered_search/filtered_search_visual_tokens_spec.js +++ b/spec/javascripts/filtered_search/filtered_search_visual_tokens_spec.js @@ -6,9 +6,10 @@ describe('Filtered Search Visual Tokens', () => { const findElements = tokenElement => { const tokenNameElement = tokenElement.querySelector('.name'); + const tokenOperatorElement = tokenElement.querySelector('.operator'); const tokenValueContainer = tokenElement.querySelector('.value-container'); const tokenValueElement = tokenValueContainer.querySelector('.value'); - return { tokenNameElement, tokenValueContainer, tokenValueElement }; + return { tokenNameElement, tokenOperatorElement, tokenValueContainer, tokenValueElement }; }; let tokensContainer; @@ -23,8 +24,8 @@ describe('Filtered Search Visual Tokens', () => { `); tokensContainer = document.querySelector('.tokens-container'); - authorToken = FilteredSearchSpecHelper.createFilterVisualToken('author', '@user'); - bugLabelToken = FilteredSearchSpecHelper.createFilterVisualToken('label', '~bug'); + authorToken = FilteredSearchSpecHelper.createFilterVisualToken('author', '=', '@user'); + bugLabelToken = FilteredSearchSpecHelper.createFilterVisualToken('label', '=', '~bug'); }); describe('getLastVisualTokenBeforeInput', () => { @@ -62,7 +63,7 @@ describe('Filtered Search Visual Tokens', () => { tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(` ${bugLabelToken.outerHTML} ${FilteredSearchSpecHelper.createSearchVisualTokenHTML('search term')} - ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('author', '@root')} + ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('author', '=', '@root')} `); const { lastVisualToken, isLastVisualTokenValid } = subject.getLastVisualTokenBeforeInput(); @@ -92,7 +93,7 @@ describe('Filtered Search Visual Tokens', () => { tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(` ${bugLabelToken.outerHTML} ${FilteredSearchSpecHelper.createInputHTML()} - ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('author', '@root')} + ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('author', '=', '@root')} `); const { lastVisualToken, isLastVisualTokenValid } = subject.getLastVisualTokenBeforeInput(); @@ -105,7 +106,7 @@ describe('Filtered Search Visual Tokens', () => { tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(` ${FilteredSearchSpecHelper.createNameFilterVisualTokenHTML('label')} ${FilteredSearchSpecHelper.createInputHTML()} - ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('author', '@root')} + ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('author', '=', '@root')} `); const { lastVisualToken, isLastVisualTokenValid } = subject.getLastVisualTokenBeforeInput(); @@ -150,8 +151,8 @@ describe('Filtered Search Visual Tokens', () => { it('removes the selected class from buttons', () => { tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(` - ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('author', '@author')} - ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', '%123', true)} + ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('author', '=', '@author')} + ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', '=', '%123', true)} `); const selected = tokensContainer.querySelector('.js-visual-token .selected'); @@ -169,7 +170,7 @@ describe('Filtered Search Visual Tokens', () => { tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(` ${bugLabelToken.outerHTML} ${FilteredSearchSpecHelper.createSearchVisualTokenHTML('search term')} - ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~awesome')} + ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '=', '~awesome')} `); }); @@ -206,7 +207,7 @@ describe('Filtered Search Visual Tokens', () => { describe('removeSelectedToken', () => { it('does not remove when there are no selected tokens', () => { tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( - FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', 'none'), + FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', '=', 'none'), ); expect(tokensContainer.querySelector('.js-visual-token .selectable')).not.toEqual(null); @@ -218,7 +219,7 @@ describe('Filtered Search Visual Tokens', () => { it('removes selected token', () => { tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( - FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', 'none', true), + FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', '=', 'none', true), ); expect(tokensContainer.querySelector('.js-visual-token .selectable')).not.toEqual(null); @@ -281,16 +282,22 @@ describe('Filtered Search Visual Tokens', () => { describe('addVisualTokenElement', () => { it('renders search visual tokens', () => { - subject.addVisualTokenElement('search term', null, { isSearchTerm: true }); + subject.addVisualTokenElement({ + name: 'search term', + operator: '=', + value: null, + options: { isSearchTerm: true }, + }); const token = tokensContainer.querySelector('.js-visual-token'); expect(token.classList.contains('filtered-search-term')).toEqual(true); expect(token.querySelector('.name').innerText).toEqual('search term'); + expect(token.querySelector('.operator').innerText).toEqual('='); expect(token.querySelector('.value')).toEqual(null); }); it('renders filter visual token name', () => { - subject.addVisualTokenElement('milestone'); + subject.addVisualTokenElement({ name: 'milestone' }); const token = tokensContainer.querySelector('.js-visual-token'); expect(token.classList.contains('search-token-milestone')).toEqual(true); @@ -299,22 +306,23 @@ describe('Filtered Search Visual Tokens', () => { expect(token.querySelector('.value')).toEqual(null); }); - it('renders filter visual token name and value', () => { - subject.addVisualTokenElement('label', 'Frontend'); + it('renders filter visual token name, operator, and value', () => { + subject.addVisualTokenElement({ name: 'label', operator: '!=', value: 'Frontend' }); const token = tokensContainer.querySelector('.js-visual-token'); expect(token.classList.contains('search-token-label')).toEqual(true); expect(token.classList.contains('filtered-search-token')).toEqual(true); expect(token.querySelector('.name').innerText).toEqual('label'); + expect(token.querySelector('.operator').innerText).toEqual('!='); expect(token.querySelector('.value').innerText).toEqual('Frontend'); }); it('inserts visual token before input', () => { tokensContainer.appendChild( - FilteredSearchSpecHelper.createFilterVisualToken('assignee', '@root'), + FilteredSearchSpecHelper.createFilterVisualToken('assignee', '=', '@root'), ); - subject.addVisualTokenElement('label', 'Frontend'); + subject.addVisualTokenElement({ name: 'label', operator: '!=', value: 'Frontend' }); const tokens = tokensContainer.querySelectorAll('.js-visual-token'); const labelToken = tokens[0]; const assigneeToken = tokens[1]; @@ -323,18 +331,20 @@ describe('Filtered Search Visual Tokens', () => { expect(labelToken.classList.contains('filtered-search-token')).toEqual(true); expect(labelToken.querySelector('.name').innerText).toEqual('label'); expect(labelToken.querySelector('.value').innerText).toEqual('Frontend'); + expect(labelToken.querySelector('.operator').innerText).toEqual('!='); expect(assigneeToken.classList.contains('search-token-assignee')).toEqual(true); expect(assigneeToken.classList.contains('filtered-search-token')).toEqual(true); expect(assigneeToken.querySelector('.name').innerText).toEqual('assignee'); expect(assigneeToken.querySelector('.value').innerText).toEqual('@root'); + expect(assigneeToken.querySelector('.operator').innerText).toEqual('='); }); }); describe('addValueToPreviousVisualTokenElement', () => { it('does not add when previous visual token element has no value', () => { tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( - FilteredSearchSpecHelper.createFilterVisualTokenHTML('author', '@root'), + FilteredSearchSpecHelper.createFilterVisualTokenHTML('author', '=', '@root'), ); const original = tokensContainer.innerHTML; @@ -345,7 +355,7 @@ describe('Filtered Search Visual Tokens', () => { it('does not add when previous visual token element is a search', () => { tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(` - ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('author', '@root')} + ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('author', '=', '@root')} ${FilteredSearchSpecHelper.createSearchVisualTokenHTML('search term')} `); @@ -357,7 +367,7 @@ describe('Filtered Search Visual Tokens', () => { it('adds value to previous visual filter token', () => { tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( - FilteredSearchSpecHelper.createNameFilterVisualTokenHTML('label'), + FilteredSearchSpecHelper.createNameOperatorFilterVisualTokenHTML('label', '='), ); const original = tokensContainer.innerHTML; @@ -377,25 +387,28 @@ describe('Filtered Search Visual Tokens', () => { expect(token.classList.contains('filtered-search-token')).toEqual(true); expect(token.querySelector('.name').innerText).toEqual('milestone'); + expect(token.querySelector('.operator')).toEqual(null); expect(token.querySelector('.value')).toEqual(null); }); it('creates visual token with just tokenValue', () => { - subject.addFilterVisualToken('milestone'); + subject.addFilterVisualToken('milestone', '='); subject.addFilterVisualToken('%8.17'); const token = tokensContainer.querySelector('.js-visual-token'); expect(token.classList.contains('filtered-search-token')).toEqual(true); expect(token.querySelector('.name').innerText).toEqual('milestone'); + expect(token.querySelector('.operator').innerText).toEqual('='); expect(token.querySelector('.value').innerText).toEqual('%8.17'); }); it('creates full visual token', () => { - subject.addFilterVisualToken('assignee', '@john'); + subject.addFilterVisualToken('assignee', '=', '@john'); const token = tokensContainer.querySelector('.js-visual-token'); expect(token.classList.contains('filtered-search-token')).toEqual(true); expect(token.querySelector('.name').innerText).toEqual('assignee'); + expect(token.querySelector('.operator').innerText).toEqual('='); expect(token.querySelector('.value').innerText).toEqual('@john'); }); }); @@ -412,7 +425,7 @@ describe('Filtered Search Visual Tokens', () => { it('appends to previous search visual token if previous token was a search token', () => { tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(` - ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('author', '@root')} + ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('author', '=', '@root')} ${FilteredSearchSpecHelper.createSearchVisualTokenHTML('search term')} `); @@ -467,7 +480,11 @@ describe('Filtered Search Visual Tokens', () => { describe('removeLastTokenPartial', () => { it('should remove the last token value if it exists', () => { tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( - FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~"Community Contribution"'), + FilteredSearchSpecHelper.createFilterVisualTokenHTML( + 'label', + '=', + '~"Community Contribution"', + ), ); expect(tokensContainer.querySelector('.js-visual-token .value')).not.toEqual(null); @@ -507,7 +524,7 @@ describe('Filtered Search Visual Tokens', () => { it('adds search visual token if previous visual token is valid', () => { tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( - FilteredSearchSpecHelper.createFilterVisualTokenHTML('assignee', 'none'), + FilteredSearchSpecHelper.createFilterVisualTokenHTML('assignee', '=', 'none'), ); const input = document.querySelector('.filtered-search'); @@ -523,7 +540,7 @@ describe('Filtered Search Visual Tokens', () => { it('adds value to previous visual token element if previous visual token is invalid', () => { tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( - FilteredSearchSpecHelper.createNameFilterVisualTokenHTML('assignee'), + FilteredSearchSpecHelper.createNameOperatorFilterVisualTokenHTML('assignee', '='), ); const input = document.querySelector('.filtered-search'); @@ -534,6 +551,7 @@ describe('Filtered Search Visual Tokens', () => { expect(input.value).toEqual(''); expect(updatedToken.querySelector('.name').innerText).toEqual('assignee'); + expect(updatedToken.querySelector('.operator').innerText).toEqual('='); expect(updatedToken.querySelector('.value').innerText).toEqual('@john'); }); }); @@ -544,9 +562,9 @@ describe('Filtered Search Visual Tokens', () => { beforeEach(() => { tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(` - ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', 'none')} + ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '=', 'none')} ${FilteredSearchSpecHelper.createSearchVisualTokenHTML('search')} - ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', 'upcoming')} + ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', '=', 'upcoming')} `); input = document.querySelector('.filtered-search'); @@ -614,7 +632,7 @@ describe('Filtered Search Visual Tokens', () => { describe('moveInputTotheRight', () => { it('does nothing if the input is already the right most element', () => { tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( - FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', 'none'), + FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '=', 'none'), ); spyOn(subject, 'tokenizeInput').and.callFake(() => {}); @@ -628,12 +646,12 @@ describe('Filtered Search Visual Tokens', () => { it("tokenize's input", () => { tokensContainer.innerHTML = ` - ${FilteredSearchSpecHelper.createNameFilterVisualTokenHTML('label')} + ${FilteredSearchSpecHelper.createNameOperatorFilterVisualTokenHTML('label', '=')} ${FilteredSearchSpecHelper.createInputHTML()} ${bugLabelToken.outerHTML} `; - document.querySelector('.filtered-search').value = 'none'; + tokensContainer.querySelector('.filtered-search').value = 'none'; subject.moveInputToTheRight(); const value = tokensContainer.querySelector('.js-visual-token .value'); @@ -643,7 +661,7 @@ describe('Filtered Search Visual Tokens', () => { it('converts input into search term token if last token is valid', () => { tokensContainer.innerHTML = ` - ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', 'none')} + ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '=', 'none')} ${FilteredSearchSpecHelper.createInputHTML()} ${bugLabelToken.outerHTML} `; @@ -658,7 +676,7 @@ describe('Filtered Search Visual Tokens', () => { it('moves the input to the right most element', () => { tokensContainer.innerHTML = ` - ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', 'none')} + ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '=', 'none')} ${FilteredSearchSpecHelper.createInputHTML()} ${bugLabelToken.outerHTML} `; @@ -670,8 +688,8 @@ describe('Filtered Search Visual Tokens', () => { it('tokenizes input even if input is the right most element', () => { tokensContainer.innerHTML = ` - ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', 'none')} - ${FilteredSearchSpecHelper.createNameFilterVisualTokenHTML('label')} + ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '=', 'none')} + ${FilteredSearchSpecHelper.createNameOperatorFilterVisualTokenHTML('label')} ${FilteredSearchSpecHelper.createInputHTML('', '~bug')} `; diff --git a/spec/javascripts/filtered_search/issues_filtered_search_token_keys_spec.js b/spec/javascripts/filtered_search/issues_filtered_search_token_keys_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..c7be900ba2c4c9ab2e02ca12265bad371b10f19c --- /dev/null +++ b/spec/javascripts/filtered_search/issues_filtered_search_token_keys_spec.js @@ -0,0 +1,148 @@ +import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys'; + +describe('Issues Filtered Search Token Keys', () => { + describe('get', () => { + let tokenKeys; + + beforeEach(() => { + tokenKeys = IssuableFilteredSearchTokenKeys.get(); + }); + + it('should return tokenKeys', () => { + expect(tokenKeys).not.toBeNull(); + }); + + it('should return tokenKeys as an array', () => { + expect(tokenKeys instanceof Array).toBe(true); + }); + + it('should always return the same array', () => { + const tokenKeys2 = IssuableFilteredSearchTokenKeys.get(); + + expect(tokenKeys).toEqual(tokenKeys2); + }); + + it('should return assignee as a string', () => { + const assignee = tokenKeys.find(tokenKey => tokenKey.key === 'assignee'); + + expect(assignee.type).toEqual('string'); + }); + }); + + describe('getKeys', () => { + it('should return keys', () => { + const getKeys = IssuableFilteredSearchTokenKeys.getKeys(); + const keys = IssuableFilteredSearchTokenKeys.get().map(i => i.key); + + keys.forEach((key, i) => { + expect(key).toEqual(getKeys[i]); + }); + }); + }); + + describe('getConditions', () => { + let conditions; + + beforeEach(() => { + conditions = IssuableFilteredSearchTokenKeys.getConditions(); + }); + + it('should return conditions', () => { + expect(conditions).not.toBeNull(); + }); + + it('should return conditions as an array', () => { + expect(conditions instanceof Array).toBe(true); + }); + }); + + describe('searchByKey', () => { + it('should return null when key not found', () => { + const tokenKey = IssuableFilteredSearchTokenKeys.searchByKey('notakey'); + + expect(tokenKey).toBeNull(); + }); + + it('should return tokenKey when found by key', () => { + const tokenKeys = IssuableFilteredSearchTokenKeys.get(); + const result = IssuableFilteredSearchTokenKeys.searchByKey(tokenKeys[0].key); + + expect(result).toEqual(tokenKeys[0]); + }); + }); + + describe('searchBySymbol', () => { + it('should return null when symbol not found', () => { + const tokenKey = IssuableFilteredSearchTokenKeys.searchBySymbol('notasymbol'); + + expect(tokenKey).toBeNull(); + }); + + it('should return tokenKey when found by symbol', () => { + const tokenKeys = IssuableFilteredSearchTokenKeys.get(); + const result = IssuableFilteredSearchTokenKeys.searchBySymbol(tokenKeys[0].symbol); + + expect(result).toEqual(tokenKeys[0]); + }); + }); + + describe('searchByKeyParam', () => { + it('should return null when key param not found', () => { + const tokenKey = IssuableFilteredSearchTokenKeys.searchByKeyParam('notakeyparam'); + + expect(tokenKey).toBeNull(); + }); + + it('should return tokenKey when found by key param', () => { + const tokenKeys = IssuableFilteredSearchTokenKeys.get(); + const result = IssuableFilteredSearchTokenKeys.searchByKeyParam( + `${tokenKeys[0].key}_${tokenKeys[0].param}`, + ); + + expect(result).toEqual(tokenKeys[0]); + }); + + it('should return alternative tokenKey when found by key param', () => { + const tokenKeys = IssuableFilteredSearchTokenKeys.getAlternatives(); + const result = IssuableFilteredSearchTokenKeys.searchByKeyParam( + `${tokenKeys[0].key}_${tokenKeys[0].param}`, + ); + + expect(result).toEqual(tokenKeys[0]); + }); + }); + + describe('searchByConditionUrl', () => { + it('should return null when condition url not found', () => { + const condition = IssuableFilteredSearchTokenKeys.searchByConditionUrl(null); + + expect(condition).toBeNull(); + }); + + it('should return condition when found by url', () => { + const conditions = IssuableFilteredSearchTokenKeys.getConditions(); + const result = IssuableFilteredSearchTokenKeys.searchByConditionUrl(conditions[0].url); + + expect(result).toBe(conditions[0]); + }); + }); + + describe('searchByConditionKeyValue', () => { + it('should return null when condition tokenKey and value not found', () => { + const condition = IssuableFilteredSearchTokenKeys.searchByConditionKeyValue(null, null); + + expect(condition).toBeNull(); + }); + + it('should return condition when found by tokenKey and value', () => { + const conditions = IssuableFilteredSearchTokenKeys.getConditions(); + const result = IssuableFilteredSearchTokenKeys.searchByConditionKeyValue( + conditions[0].tokenKey, + conditions[0].operator, + conditions[0].value, + ); + + expect(result).toEqual(conditions[0]); + }); + }); +}); diff --git a/spec/javascripts/filtered_search/visual_token_value_spec.js b/spec/javascripts/filtered_search/visual_token_value_spec.js index 5863005de1ee41a34ca3a2fb49fca02edd8c9545..a039e28002897443349e3cc873473168cdfcf4e9 100644 --- a/spec/javascripts/filtered_search/visual_token_value_spec.js +++ b/spec/javascripts/filtered_search/visual_token_value_spec.js @@ -10,9 +10,11 @@ describe('Filtered Search Visual Tokens', () => { const tokenNameElement = tokenElement.querySelector('.name'); const tokenValueContainer = tokenElement.querySelector('.value-container'); const tokenValueElement = tokenValueContainer.querySelector('.value'); + const tokenOperatorElement = tokenElement.querySelector('.operator'); const tokenType = tokenNameElement.innerText.toLowerCase(); const tokenValue = tokenValueElement.innerText; - const subject = new VisualTokenValue(tokenValue, tokenType); + const tokenOperator = tokenOperatorElement.innerText; + const subject = new VisualTokenValue(tokenValue, tokenType, tokenOperator); return { subject, tokenValueContainer, tokenValueElement }; }; @@ -28,8 +30,8 @@ describe('Filtered Search Visual Tokens', () => { `); tokensContainer = document.querySelector('.tokens-container'); - authorToken = FilteredSearchSpecHelper.createFilterVisualToken('author', '@user'); - bugLabelToken = FilteredSearchSpecHelper.createFilterVisualToken('label', '~bug'); + authorToken = FilteredSearchSpecHelper.createFilterVisualToken('author', '=', '@user'); + bugLabelToken = FilteredSearchSpecHelper.createFilterVisualToken('label', '=', '~bug'); }); describe('updateUserTokenAppearance', () => { @@ -140,10 +142,12 @@ describe('Filtered Search Visual Tokens', () => { const missingLabelToken = FilteredSearchSpecHelper.createFilterVisualToken( 'label', + '=', '~doesnotexist', ); const spaceLabelToken = FilteredSearchSpecHelper.createFilterVisualToken( 'label', + '=', '~"some space"', ); diff --git a/spec/javascripts/fly_out_nav_spec.js b/spec/javascripts/fly_out_nav_spec.js index 4772f754937ad18bec3593e1363d4182aa894c9f..afcf132bea3589d524b2a288915937f6776d5547 100644 --- a/spec/javascripts/fly_out_nav_spec.js +++ b/spec/javascripts/fly_out_nav_spec.js @@ -1,3 +1,4 @@ +import { GlBreakpointInstance } from '@gitlab/ui/dist/utils'; import { calculateTop, showSubLevelItems, @@ -15,7 +16,6 @@ import { subItemsMouseLeave, } from '~/fly_out_nav'; import { SIDEBAR_COLLAPSED_CLASS } from '~/contextual_sidebar'; -import bp from '~/breakpoints'; describe('Fly out sidebar navigation', () => { let el; @@ -26,7 +26,7 @@ describe('Fly out sidebar navigation', () => { el.style.position = 'relative'; document.body.appendChild(el); - spyOn(bp, 'getBreakpointSize').and.callFake(() => breakpointSize); + spyOn(GlBreakpointInstance, 'getBreakpointSize').and.callFake(() => breakpointSize); setOpenMenu(null); }); diff --git a/spec/javascripts/frequent_items/components/frequent_items_list_item_spec.js b/spec/javascripts/frequent_items/components/frequent_items_list_item_spec.js index 9bf3e02557f1fb01084ad48dd1046b073c513411..e3f05e89a2d12786bb7ab79e0f8e259c830c64b5 100644 --- a/spec/javascripts/frequent_items/components/frequent_items_list_item_spec.js +++ b/spec/javascripts/frequent_items/components/frequent_items_list_item_spec.js @@ -18,7 +18,6 @@ describe('FrequentItemsListItemComponent', () => { avatarUrl: mockProject.avatarUrl, ...props, }, - sync: false, localVue, }); }; diff --git a/spec/javascripts/frequent_items/utils_spec.js b/spec/javascripts/frequent_items/utils_spec.js index cd27d79b29a2da7c1cbf21dd5b237b9f5780bff0..2480af5b31df15d9133509caaf62c635ea84fbb1 100644 --- a/spec/javascripts/frequent_items/utils_spec.js +++ b/spec/javascripts/frequent_items/utils_spec.js @@ -1,10 +1,16 @@ -import bp from '~/breakpoints'; +import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import { isMobile, getTopFrequentItems, updateExistingFrequentItem } from '~/frequent_items/utils'; import { HOUR_IN_MS, FREQUENT_ITEMS } from '~/frequent_items/constants'; import { mockProject, unsortedFrequentItems, sortedFrequentItems } from './mock_data'; describe('Frequent Items utils spec', () => { describe('isMobile', () => { + it('returns true when the screen is medium ', () => { + spyOn(bp, 'getBreakpointSize').and.returnValue('md'); + + expect(isMobile()).toBe(true); + }); + it('returns true when the screen is small ', () => { spyOn(bp, 'getBreakpointSize').and.returnValue('sm'); @@ -17,8 +23,8 @@ describe('Frequent Items utils spec', () => { expect(isMobile()).toBe(true); }); - it('returns false when the screen is larger than small ', () => { - spyOn(bp, 'getBreakpointSize').and.returnValue('md'); + it('returns false when the screen is larger than medium ', () => { + spyOn(bp, 'getBreakpointSize').and.returnValue('lg'); expect(isMobile()).toBe(false); }); @@ -32,21 +38,21 @@ describe('Frequent Items utils spec', () => { }); it('returns correct amount of items for mobile', () => { - spyOn(bp, 'getBreakpointSize').and.returnValue('sm'); + spyOn(bp, 'getBreakpointSize').and.returnValue('md'); const result = getTopFrequentItems(unsortedFrequentItems); expect(result.length).toBe(FREQUENT_ITEMS.LIST_COUNT_MOBILE); }); it('returns correct amount of items for desktop', () => { - spyOn(bp, 'getBreakpointSize').and.returnValue('lg'); + spyOn(bp, 'getBreakpointSize').and.returnValue('xl'); const result = getTopFrequentItems(unsortedFrequentItems); expect(result.length).toBe(FREQUENT_ITEMS.LIST_COUNT_DESKTOP); }); it('sorts frequent items in order of frequency and lastAccessedOn', () => { - spyOn(bp, 'getBreakpointSize').and.returnValue('lg'); + spyOn(bp, 'getBreakpointSize').and.returnValue('xl'); const result = getTopFrequentItems(unsortedFrequentItems); const expectedResult = sortedFrequentItems.slice(0, FREQUENT_ITEMS.LIST_COUNT_DESKTOP); diff --git a/spec/javascripts/helpers/filtered_search_spec_helper.js b/spec/javascripts/helpers/filtered_search_spec_helper.js index fd06bb1f3244d30fae8c21d7940fac312cd9c44c..ceb7982bbc3fc2af7edd3a07a99ddeb1a16c38f8 100644 --- a/spec/javascripts/helpers/filtered_search_spec_helper.js +++ b/spec/javascripts/helpers/filtered_search_spec_helper.js @@ -1,15 +1,17 @@ export default class FilteredSearchSpecHelper { - static createFilterVisualTokenHTML(name, value, isSelected) { - return FilteredSearchSpecHelper.createFilterVisualToken(name, value, isSelected).outerHTML; + static createFilterVisualTokenHTML(name, operator, value, isSelected) { + return FilteredSearchSpecHelper.createFilterVisualToken(name, operator, value, isSelected) + .outerHTML; } - static createFilterVisualToken(name, value, isSelected = false) { + static createFilterVisualToken(name, operator, value, isSelected = false) { const li = document.createElement('li'); li.classList.add('js-visual-token', 'filtered-search-token', `search-token-${name}`); li.innerHTML = ` <div class="selectable ${isSelected ? 'selected' : ''}" role="button"> <div class="name">${name}</div> + <div class="operator">${operator}</div> <div class="value-container"> <div class="value">${value}</div> <div class="remove-token" role="button"> @@ -30,6 +32,15 @@ export default class FilteredSearchSpecHelper { `; } + static createNameOperatorFilterVisualTokenHTML(name, operator) { + return ` + <li class="js-visual-token filtered-search-token"> + <div class="name">${name}</div> + <div class="operator">${operator}</div> + </li> + `; + } + static createSearchVisualToken(name) { const li = document.createElement('li'); li.classList.add('js-visual-token', 'filtered-search-term'); diff --git a/spec/javascripts/ide/components/commit_sidebar/form_spec.js b/spec/javascripts/ide/components/commit_sidebar/form_spec.js index fdbabf84e254e6d5bc4f68405599c29f135d165b..e984389bd46eda2878eef2af4f34488c5fd1a6a0 100644 --- a/spec/javascripts/ide/components/commit_sidebar/form_spec.js +++ b/spec/javascripts/ide/components/commit_sidebar/form_spec.js @@ -33,6 +33,12 @@ describe('IDE commit form', () => { }); describe('compact', () => { + beforeEach(done => { + vm.isCompact = true; + + vm.$nextTick(done); + }); + it('renders commit button in compact mode', () => { expect(vm.$el.querySelector('.btn-primary')).not.toBeNull(); expect(vm.$el.querySelector('.btn-primary').textContent).toContain('Commit'); @@ -61,7 +67,7 @@ describe('IDE commit form', () => { }); }); - it('toggles activity bar vie when clicking commit button', done => { + it('toggles activity bar view when clicking commit button', done => { vm.$el.querySelector('.btn-primary').click(); vm.$nextTick(() => { @@ -70,6 +76,25 @@ describe('IDE commit form', () => { done(); }); }); + + it('collapses if lastCommitMsg is set to empty and current view is not commit view', done => { + store.state.lastCommitMsg = 'abc'; + store.state.currentActivityView = activityBarViews.edit; + + vm.$nextTick(() => { + // if commit message is set, form is uncollapsed + expect(vm.isCompact).toBe(false); + + store.state.lastCommitMsg = ''; + + vm.$nextTick(() => { + // collapsed when set to empty + expect(vm.isCompact).toBe(true); + + done(); + }); + }); + }); }); describe('full', () => { @@ -104,6 +129,17 @@ describe('IDE commit form', () => { }); }); + it('always opens itself in full view current activity view is not commit view when clicking commit button', done => { + vm.$el.querySelector('.btn-primary').click(); + + vm.$nextTick(() => { + expect(store.state.currentActivityView).toBe(activityBarViews.commit); + expect(vm.isCompact).toBe(false); + + done(); + }); + }); + describe('discard draft button', () => { it('hidden when commitMessage is empty', () => { expect(vm.$el.querySelector('.btn-default').textContent).toContain('Collapse'); diff --git a/spec/javascripts/ide/components/commit_sidebar/list_item_spec.js b/spec/javascripts/ide/components/commit_sidebar/list_item_spec.js index caf06b5e1d842c5dc9c63770019056cf272fc456..63ba6b956193dcbe677fe75982515b7e916ac36a 100644 --- a/spec/javascripts/ide/components/commit_sidebar/list_item_spec.js +++ b/spec/javascripts/ide/components/commit_sidebar/list_item_spec.js @@ -20,7 +20,6 @@ describe('Multi-file editor commit sidebar list item', () => { vm = createComponentWithStore(Component, store, { file: f, - actionComponent: 'stage-button', activeFileKey: `staged-${f.key}`, }).$mount(); diff --git a/spec/javascripts/ide/components/commit_sidebar/list_spec.js b/spec/javascripts/ide/components/commit_sidebar/list_spec.js index 81120f6d27722d3d85fc9df8eb14487884243f4b..5a1682523d8fa8f42ae8feb8c8f7adbf8079fd74 100644 --- a/spec/javascripts/ide/components/commit_sidebar/list_spec.js +++ b/spec/javascripts/ide/components/commit_sidebar/list_spec.js @@ -17,7 +17,6 @@ describe('Multi-file editor commit sidebar list', () => { action: 'stageAllChanges', actionBtnText: 'stage all', actionBtnIcon: 'history', - itemActionComponent: 'stage-button', activeFileKey: 'staged-testing', keyPrefix: 'staged', }); diff --git a/spec/javascripts/ide/components/commit_sidebar/stage_button_spec.js b/spec/javascripts/ide/components/commit_sidebar/stage_button_spec.js deleted file mode 100644 index e09ccbe2a63d0782529368d053e5ac05aa083f20..0000000000000000000000000000000000000000 --- a/spec/javascripts/ide/components/commit_sidebar/stage_button_spec.js +++ /dev/null @@ -1,46 +0,0 @@ -import Vue from 'vue'; -import store from '~/ide/stores'; -import stageButton from '~/ide/components/commit_sidebar/stage_button.vue'; -import { createComponentWithStore } from '../../../helpers/vue_mount_component_helper'; -import { file, resetStore } from '../../helpers'; - -describe('IDE stage file button', () => { - let vm; - let f; - - beforeEach(() => { - const Component = Vue.extend(stageButton); - f = file(); - - vm = createComponentWithStore(Component, store, { - path: f.path, - }); - - spyOn(vm, 'stageChange'); - spyOn(vm, 'discardFileChanges'); - - vm.$mount(); - }); - - afterEach(() => { - vm.$destroy(); - - resetStore(vm.$store); - }); - - it('renders button to discard & stage', () => { - expect(vm.$el.querySelectorAll('.btn-blank').length).toBe(2); - }); - - it('calls store with stage button', () => { - vm.$el.querySelectorAll('.btn')[0].click(); - - expect(vm.stageChange).toHaveBeenCalledWith(f.path); - }); - - it('calls store with discard button', () => { - vm.$el.querySelector('.btn-danger').click(); - - expect(vm.discardFileChanges).toHaveBeenCalledWith(f.path); - }); -}); diff --git a/spec/javascripts/ide/components/commit_sidebar/unstage_button_spec.js b/spec/javascripts/ide/components/commit_sidebar/unstage_button_spec.js deleted file mode 100644 index 917bbb9fb46a111859a8f7d4f2d114d29629a1b1..0000000000000000000000000000000000000000 --- a/spec/javascripts/ide/components/commit_sidebar/unstage_button_spec.js +++ /dev/null @@ -1,39 +0,0 @@ -import Vue from 'vue'; -import store from '~/ide/stores'; -import unstageButton from '~/ide/components/commit_sidebar/unstage_button.vue'; -import { createComponentWithStore } from '../../../helpers/vue_mount_component_helper'; -import { file, resetStore } from '../../helpers'; - -describe('IDE unstage file button', () => { - let vm; - let f; - - beforeEach(() => { - const Component = Vue.extend(unstageButton); - f = file(); - - vm = createComponentWithStore(Component, store, { - path: f.path, - }); - - spyOn(vm, 'unstageChange'); - - vm.$mount(); - }); - - afterEach(() => { - vm.$destroy(); - - resetStore(vm.$store); - }); - - it('renders button to unstage', () => { - expect(vm.$el.querySelectorAll('.btn').length).toBe(1); - }); - - it('calls store with unnstage button', () => { - vm.$el.querySelector('.btn').click(); - - expect(vm.unstageChange).toHaveBeenCalledWith(f.path); - }); -}); diff --git a/spec/javascripts/ide/components/new_dropdown/modal_spec.js b/spec/javascripts/ide/components/new_dropdown/modal_spec.js index a1c00e999272b6bdad17b439993337a2cc929778..0ea767e087dcc852942f7b6aed5276e5049fbab9 100644 --- a/spec/javascripts/ide/components/new_dropdown/modal_spec.js +++ b/spec/javascripts/ide/components/new_dropdown/modal_spec.js @@ -52,19 +52,6 @@ describe('new file modal component', () => { expect(templateFilesEl instanceof Element).toBeTruthy(); } }); - - describe('createEntryInStore', () => { - it('$emits create', () => { - spyOn(vm, 'createTempEntry'); - - vm.submitForm(); - - expect(vm.createTempEntry).toHaveBeenCalledWith({ - name: 'testing', - type, - }); - }); - }); }); }); @@ -145,31 +132,19 @@ describe('new file modal component', () => { vm = createComponentWithStore(Component, store).$mount(); const flashSpy = spyOnDependency(modal, 'flash'); - vm.submitForm(); - expect(flashSpy).toHaveBeenCalled(); - }); + expect(flashSpy).not.toHaveBeenCalled(); - it('calls createTempEntry when target path does not exist', () => { - const store = createStore(); - store.state.entryModal = { - type: 'rename', - path: 'test-path/test', - entry: { - name: 'test', - type: 'blob', - path: 'test-path1/test', - }, - }; - - vm = createComponentWithStore(Component, store).$mount(); - spyOn(vm, 'createTempEntry').and.callFake(() => Promise.resolve()); vm.submitForm(); - expect(vm.createTempEntry).toHaveBeenCalledWith({ - name: 'test-path1', - type: 'tree', - }); + expect(flashSpy).toHaveBeenCalledWith( + 'The name "test-path/test" is already taken in this directory.', + 'alert', + jasmine.anything(), + null, + false, + true, + ); }); }); }); diff --git a/spec/javascripts/ide/components/repo_editor_spec.js b/spec/javascripts/ide/components/repo_editor_spec.js index 21fb5449858a96444b39095af1c785a643c63dd4..8935d8f56fca015a58d52a430f8858ec9e85b99d 100644 --- a/spec/javascripts/ide/components/repo_editor_spec.js +++ b/spec/javascripts/ide/components/repo_editor_spec.js @@ -52,6 +52,18 @@ describe('RepoEditor', () => { state.rightPanelCollapsed = !state.rightPanelCollapsed; }; + it('sets renderWhitespace to `all`', () => { + vm.$store.state.renderWhitespaceInCode = true; + + expect(vm.editorOptions.renderWhitespace).toEqual('all'); + }); + + it('sets renderWhitespace to `none`', () => { + vm.$store.state.renderWhitespaceInCode = false; + + expect(vm.editorOptions.renderWhitespace).toEqual('none'); + }); + it('renders an ide container', () => { expect(vm.shouldHideEditor).toBeFalsy(); expect(vm.showEditor).toBe(true); diff --git a/spec/javascripts/ide/components/repo_tab_spec.js b/spec/javascripts/ide/components/repo_tab_spec.js index 3b52f279bf2ed50583992f5704de7d35f41b4eaf..7466ed5468b31dde7075e32e0da9e000b3dfd8e2 100644 --- a/spec/javascripts/ide/components/repo_tab_spec.js +++ b/spec/javascripts/ide/components/repo_tab_spec.js @@ -93,13 +93,13 @@ describe('RepoTab', () => { Vue.nextTick() .then(() => { - expect(vm.$el.querySelector('.file-modified')).toBeNull(); + expect(vm.$el.querySelector('.file-modified-solid')).toBeNull(); vm.$el.dispatchEvent(new Event('mouseout')); }) .then(Vue.nextTick) .then(() => { - expect(vm.$el.querySelector('.file-modified')).not.toBeNull(); + expect(vm.$el.querySelector('.file-modified-solid')).not.toBeNull(); done(); }) diff --git a/spec/javascripts/ide/lib/editor_spec.js b/spec/javascripts/ide/lib/editor_spec.js index c2cb964ea8727e5f0cf1f74fd8d3ac27f3be5933..f1973f7798f768dde08c5b5fff72c56c1bac6275 100644 --- a/spec/javascripts/ide/lib/editor_spec.js +++ b/spec/javascripts/ide/lib/editor_spec.js @@ -67,6 +67,7 @@ describe('Multi-file editor library', () => { }, readOnly: true, scrollBeyondLastLine: false, + renderWhitespace: 'none', quickSuggestions: false, occurrencesHighlight: false, wordWrap: 'on', diff --git a/spec/javascripts/ide/stores/actions/merge_request_spec.js b/spec/javascripts/ide/stores/actions/merge_request_spec.js index a8894c644be688b3380d0be6d7eae3bc1141bbc1..ca8f33407fde3eda41fa297cbbbe3e513f3d45f3 100644 --- a/spec/javascripts/ide/stores/actions/merge_request_spec.js +++ b/spec/javascripts/ide/stores/actions/merge_request_spec.js @@ -348,6 +348,8 @@ describe('IDE store merge request actions', () => { let testMergeRequest; let testMergeRequestChanges; + const mockGetters = { findBranch: () => ({ commit: { id: 'abcd2322' } }) }; + beforeEach(() => { testMergeRequest = { source_branch: 'abcbranch', @@ -406,8 +408,8 @@ describe('IDE store merge request actions', () => { ); }); - it('dispatch actions for merge request data', done => { - openMergeRequest(store, mr) + it('dispatches actions for merge request data', done => { + openMergeRequest({ state: store.state, dispatch: store.dispatch, getters: mockGetters }, mr) .then(() => { expect(store.dispatch.calls.allArgs()).toEqual([ ['getMergeRequestData', mr], @@ -424,6 +426,7 @@ describe('IDE store merge request actions', () => { { projectId: mr.projectId, branchId: testMergeRequest.source_branch, + ref: 'abcd2322', }, ], ['getMergeRequestVersions', mr], @@ -449,7 +452,7 @@ describe('IDE store merge request actions', () => { { new_path: 'bar', path: 'bar' }, ]; - openMergeRequest(store, mr) + openMergeRequest({ state: store.state, dispatch: store.dispatch, getters: mockGetters }, mr) .then(() => { expect(store.dispatch).toHaveBeenCalledWith( 'updateActivityBarView', diff --git a/spec/javascripts/ide/stores/actions/project_spec.js b/spec/javascripts/ide/stores/actions/project_spec.js index bcc7b5d5e46ede1037063aa4b3f7271706b89b03..bd51222ac3c7d336279ea25f0f1c662473685f07 100644 --- a/spec/javascripts/ide/stores/actions/project_spec.js +++ b/spec/javascripts/ide/stores/actions/project_spec.js @@ -201,35 +201,30 @@ describe('IDE store project actions', () => { }); describe('showEmptyState', () => { - it('commits proper mutations when supplied error is 404', done => { + it('creates a blank tree and sets loading state to false', done => { testAction( showEmptyState, - { - err: { - response: { - status: 404, - }, - }, - projectId: 'abc/def', - branchId: 'master', - }, + { projectId: 'abc/def', branchId: 'master' }, store.state, [ - { - type: 'CREATE_TREE', - payload: { - treePath: 'abc/def/master', - }, - }, + { type: 'CREATE_TREE', payload: { treePath: 'abc/def/master' } }, { type: 'TOGGLE_LOADING', - payload: { - entry: store.state.trees['abc/def/master'], - forceValue: false, - }, + payload: { entry: store.state.trees['abc/def/master'], forceValue: false }, }, ], - [], + jasmine.any(Object), + done, + ); + }); + + it('sets the currentBranchId to the branchId that was passed', done => { + testAction( + showEmptyState, + { projectId: 'abc/def', branchId: 'master' }, + store.state, + jasmine.any(Object), + [{ type: 'setCurrentBranchId', payload: 'master' }], done, ); }); @@ -285,16 +280,21 @@ describe('IDE store project actions', () => { describe('loadBranch', () => { const projectId = 'abc/def'; const branchId = '123-lorem'; + const ref = 'abcd2322'; it('fetches branch data', done => { + const mockGetters = { findBranch: () => ({ commit: { id: ref } }) }; spyOn(store, 'dispatch').and.returnValue(Promise.resolve()); - loadBranch(store, { projectId, branchId }) + loadBranch( + { getters: mockGetters, state: store.state, dispatch: store.dispatch }, + { projectId, branchId }, + ) .then(() => { expect(store.dispatch.calls.allArgs()).toEqual([ ['getBranchData', { projectId, branchId }], ['getMergeRequestsForBranch', { projectId, branchId }], - ['getFiles', { projectId, branchId }], + ['getFiles', { projectId, branchId, ref }], ]); }) .then(done) diff --git a/spec/javascripts/ide/stores/actions/tree_spec.js b/spec/javascripts/ide/stores/actions/tree_spec.js index e2d8cc195aef611b19767e98a44ea509db7741f7..be350b6f6cc1f488cfa74b0ba3c1cc1d11110322 100644 --- a/spec/javascripts/ide/stores/actions/tree_spec.js +++ b/spec/javascripts/ide/stores/actions/tree_spec.js @@ -17,6 +17,7 @@ describe('Multi-file store tree actions', () => { projectId: 'abcproject', branch: 'master', branchId: 'master', + ref: '12345678', }; beforeEach(() => { @@ -29,14 +30,6 @@ describe('Multi-file store tree actions', () => { store.state.currentBranchId = 'master'; store.state.projects.abcproject = { web_url: '', - branches: { - master: { - workingReference: '12345678', - commit: { - id: '12345678', - }, - }, - }, }; }); diff --git a/spec/javascripts/ide/stores/actions_spec.js b/spec/javascripts/ide/stores/actions_spec.js index 0ee114cb70da9bbe3d990ad2c02600f94f0b6f8c..d582462d542e42e69f7af569a74910d487f9fa48 100644 --- a/spec/javascripts/ide/stores/actions_spec.js +++ b/spec/javascripts/ide/stores/actions_spec.js @@ -18,19 +18,19 @@ import axios from '~/lib/utils/axios_utils'; import { createStore } from '~/ide/stores'; import * as types from '~/ide/stores/mutation_types'; import router from '~/ide/ide_router'; -import { resetStore, file } from '../helpers'; +import { file } from '../helpers'; import testAction from '../../helpers/vuex_action_helper'; import eventHub from '~/ide/eventhub'; -const store = createStore(); - describe('Multi-file store actions', () => { + let store; + beforeEach(() => { - spyOn(router, 'push'); - }); + store = createStore(); - afterEach(() => { - resetStore(store); + spyOn(store, 'commit').and.callThrough(); + spyOn(store, 'dispatch').and.callThrough(); + spyOn(router, 'push'); }); describe('redirectToUrl', () => { @@ -61,24 +61,25 @@ describe('Multi-file store actions', () => { }); describe('discardAllChanges', () => { - let f; + const paths = ['to_discard', 'another_one_to_discard']; + beforeEach(() => { - f = file('discardAll'); - f.changed = true; + paths.forEach(path => { + const f = file(path); + f.changed = true; - store.state.openFiles.push(f); - store.state.changedFiles.push(f); - store.state.entries[f.path] = f; + store.state.openFiles.push(f); + store.state.changedFiles.push(f); + store.state.entries[f.path] = f; + }); }); - it('discards changes in file', done => { - store - .dispatch('discardAllChanges') - .then(() => { - expect(store.state.openFiles.changed).toBeFalsy(); - }) - .then(done) - .catch(done.fail); + it('discards all changes in file', () => { + const expectedCalls = paths.map(path => ['restoreOriginalFile', path]); + + discardAllChanges(store); + + expect(store.dispatch.calls.allArgs()).toEqual(jasmine.arrayContaining(expectedCalls)); }); it('removes all files from changedFiles state', done => { @@ -86,64 +87,11 @@ describe('Multi-file store actions', () => { .dispatch('discardAllChanges') .then(() => { expect(store.state.changedFiles.length).toBe(0); - expect(store.state.openFiles.length).toBe(1); + expect(store.state.openFiles.length).toBe(2); }) .then(done) .catch(done.fail); }); - - it('closes the temp file and deletes it if it was open', done => { - f.tempFile = true; - - testAction( - discardAllChanges, - undefined, - store.state, - [{ type: types.REMOVE_ALL_CHANGES_FILES }], - [ - { type: 'closeFile', payload: jasmine.objectContaining({ path: 'discardAll' }) }, - { type: 'deleteEntry', payload: 'discardAll' }, - ], - done, - ); - }); - - it('renames the file to its original name and closes it if it was open', done => { - Object.assign(f, { - prevPath: 'parent/path/old_name', - prevName: 'old_name', - prevParentPath: 'parent/path', - }); - - testAction( - discardAllChanges, - undefined, - store.state, - [{ type: types.REMOVE_ALL_CHANGES_FILES }], - [ - { type: 'closeFile', payload: jasmine.objectContaining({ path: 'discardAll' }) }, - { - type: 'renameEntry', - payload: { path: 'discardAll', name: 'old_name', parentPath: 'parent/path' }, - }, - ], - done, - ); - }); - - it('discards file changes on all other files', done => { - testAction( - discardAllChanges, - undefined, - store.state, - [ - { type: types.DISCARD_FILE_CHANGES, payload: 'discardAll' }, - { type: types.REMOVE_ALL_CHANGES_FILES }, - ], - [], - done, - ); - }); }); describe('closeAllFiles', () => { @@ -258,13 +206,17 @@ describe('Multi-file store actions', () => { describe('blob', () => { it('creates temp file', done => { + const name = 'test'; + store .dispatch('createTempEntry', { - name: 'test', + name, branchId: 'mybranch', type: 'blob', }) - .then(f => { + .then(() => { + const f = store.state.entries[name]; + expect(f.tempFile).toBeTruthy(); expect(store.state.trees['abcproject/mybranch'].tree.length).toBe(1); @@ -273,14 +225,47 @@ describe('Multi-file store actions', () => { .catch(done.fail); }); + describe('when `gon.feature.stageAllByDefault` is true', () => { + const originalGonFeatures = Object.assign({}, gon.features); + + beforeAll(() => { + gon.features = { stageAllByDefault: true }; + }); + + afterAll(() => { + gon.features = originalGonFeatures; + }); + + it('adds tmp file to staged files', done => { + const name = 'test'; + + store + .dispatch('createTempEntry', { + name, + branchId: 'mybranch', + type: 'blob', + }) + .then(() => { + expect(store.state.stagedFiles).toEqual([jasmine.objectContaining({ name })]); + + done(); + }) + .catch(done.fail); + }); + }); + it('adds tmp file to open files', done => { + const name = 'test'; + store .dispatch('createTempEntry', { - name: 'test', + name, branchId: 'mybranch', type: 'blob', }) - .then(f => { + .then(() => { + const f = store.state.entries[name]; + expect(store.state.openFiles.length).toBe(1); expect(store.state.openFiles[0].name).toBe(f.name); @@ -290,46 +275,34 @@ describe('Multi-file store actions', () => { }); it('adds tmp file to changed files', done => { + const name = 'test'; + store .dispatch('createTempEntry', { - name: 'test', + name, branchId: 'mybranch', type: 'blob', }) - .then(f => { - expect(store.state.changedFiles.length).toBe(1); - expect(store.state.changedFiles[0].name).toBe(f.name); + .then(() => { + expect(store.state.changedFiles).toEqual([ + jasmine.objectContaining({ name, tempFile: true }), + ]); done(); }) .catch(done.fail); }); - it('sets tmp file as active', done => { - testAction( - createTempEntry, - { - name: 'test', - branchId: 'mybranch', - type: 'blob', - }, - store.state, - [ - { type: types.CREATE_TMP_ENTRY, payload: jasmine.any(Object) }, - { type: types.TOGGLE_FILE_OPEN, payload: 'test' }, - { type: types.ADD_FILE_TO_CHANGED, payload: 'test' }, - ], - [ - { - type: 'setFileActive', - payload: 'test', - }, - { - type: 'triggerFilesChange', - }, - ], - done, + it('sets tmp file as active', () => { + const dispatch = jasmine.createSpy(); + const commit = jasmine.createSpy(); + + createTempEntry( + { state: store.state, getters: store.getters, dispatch, commit }, + { name: 'test', branchId: 'mybranch', type: 'blob' }, ); + + expect(dispatch).toHaveBeenCalledWith('setFileActive', 'test'); }); it('creates flash message if file already exists', done => { @@ -344,7 +317,24 @@ describe('Multi-file store actions', () => { type: 'blob', }) .then(() => { - expect(document.querySelector('.flash-alert')).not.toBeNull(); + expect(document.querySelector('.flash-alert')?.textContent.trim()).toEqual( + `The name "${f.name}" is already taken in this directory.`, + ); + + done(); + }) + .catch(done.fail); + }); + + it('bursts unused seal', done => { + store + .dispatch('createTempEntry', { + name: 'test', + branchId: 'mybranch', + type: 'blob', + }) + .then(() => { + expect(store.state.unusedSeal).toBe(false); done(); }) @@ -375,58 +365,82 @@ describe('Multi-file store actions', () => { }); }); - describe('stageAllChanges', () => { - it('adds all files from changedFiles to stagedFiles', done => { - const openFile = { ...file(), path: 'test' }; + describe('stage/unstageAllChanges', () => { + let file1; + let file2; - store.state.openFiles.push(openFile); - store.state.stagedFiles.push(openFile); - store.state.changedFiles.push(openFile, file('new')); + beforeEach(() => { + file1 = { ...file('test'), content: 'changed test', raw: 'test' }; + file2 = { ...file('test2'), content: 'changed test2', raw: 'test2' }; - testAction( - stageAllChanges, - null, - store.state, - [ - { type: types.SET_LAST_COMMIT_MSG, payload: '' }, - { type: types.STAGE_CHANGE, payload: store.state.changedFiles[0].path }, - { type: types.STAGE_CHANGE, payload: store.state.changedFiles[1].path }, - ], - [ - { - type: 'openPendingTab', - payload: { file: openFile, keyPrefix: 'staged' }, - }, - ], - done, - ); + store.state.openFiles = [file1]; + store.state.changedFiles = [file1]; + store.state.stagedFiles = [{ ...file2, content: 'staged test' }]; + + store.state.entries = { + [file1.path]: { ...file1 }, + [file2.path]: { ...file2 }, + }; }); - }); - describe('unstageAllChanges', () => { - it('removes all files from stagedFiles after unstaging', done => { - const openFile = { ...file(), path: 'test' }; + describe('stageAllChanges', () => { + it('adds all files from changedFiles to stagedFiles', () => { + stageAllChanges(store); + + expect(store.commit.calls.allArgs()).toEqual([ + [types.SET_LAST_COMMIT_MSG, ''], + [types.STAGE_CHANGE, jasmine.objectContaining({ path: file1.path })], + ]); + }); - store.state.openFiles.push(openFile); - store.state.changedFiles.push(openFile); - store.state.stagedFiles.push(openFile, file('new')); + it('opens pending tab if a change exists in that file', () => { + stageAllChanges(store); - testAction( - unstageAllChanges, - null, - store.state, - [ - { type: types.UNSTAGE_CHANGE, payload: store.state.stagedFiles[0].path }, - { type: types.UNSTAGE_CHANGE, payload: store.state.stagedFiles[1].path }, - ], - [ - { - type: 'openPendingTab', - payload: { file: openFile, keyPrefix: 'unstaged' }, - }, - ], - done, - ); + expect(store.dispatch.calls.allArgs()).toEqual([ + [ + 'openPendingTab', + { file: { ...file1, staged: true, changed: true }, keyPrefix: 'staged' }, + ], + ]); + }); + + it('does not open pending tab if no change exists in that file', () => { + store.state.entries[file1.path].content = 'test'; + store.state.stagedFiles = [file1]; + store.state.changedFiles = [store.state.entries[file1.path]]; + + stageAllChanges(store); + + expect(store.dispatch).not.toHaveBeenCalled(); + }); + }); + + describe('unstageAllChanges', () => { + it('removes all files from stagedFiles after unstaging', () => { + unstageAllChanges(store); + + expect(store.commit.calls.allArgs()).toEqual([ + [types.UNSTAGE_CHANGE, jasmine.objectContaining({ path: file2.path })], + ]); + }); + + it('opens pending tab if a change exists in that file', () => { + unstageAllChanges(store); + + expect(store.dispatch.calls.allArgs()).toEqual([ + ['openPendingTab', { file: file1, keyPrefix: 'unstaged' }], + ]); + }); + + it('does not open pending tab if no change exists in that file', () => { + store.state.entries[file1.path].content = 'test'; + store.state.stagedFiles = [file1]; + store.state.changedFiles = [store.state.entries[file1.path]]; + + unstageAllChanges(store); + + expect(store.dispatch).not.toHaveBeenCalled(); + }); }); }); @@ -617,36 +631,111 @@ describe('Multi-file store actions', () => { ); }); - it('if renamed, reverts the rename before deleting', () => { - const testEntry = { - path: 'test', - name: 'test', - prevPath: 'lorem/ipsum', - prevName: 'ipsum', - prevParentPath: 'lorem', - }; + describe('when renamed', () => { + let testEntry; - store.state.entries = { test: testEntry }; - testAction( - deleteEntry, - testEntry.path, - store.state, - [], - [ - { - type: 'renameEntry', - payload: { - path: testEntry.path, - name: testEntry.prevName, - parentPath: testEntry.prevParentPath, - }, - }, - { - type: 'deleteEntry', - payload: testEntry.prevPath, - }, - ], - ); + beforeEach(() => { + testEntry = { + path: 'test', + name: 'test', + prevPath: 'test_old', + prevName: 'test_old', + prevParentPath: '', + }; + + store.state.entries = { test: testEntry }; + }); + + describe('and previous does not exist', () => { + it('reverts the rename before deleting', done => { + testAction( + deleteEntry, + testEntry.path, + store.state, + [], + [ + { + type: 'renameEntry', + payload: { + path: testEntry.path, + name: testEntry.prevName, + parentPath: testEntry.prevParentPath, + }, + }, + { + type: 'deleteEntry', + payload: testEntry.prevPath, + }, + ], + done, + ); + }); + }); + + describe('and previous exists', () => { + beforeEach(() => { + const oldEntry = { + path: testEntry.prevPath, + name: testEntry.prevName, + }; + + store.state.entries[oldEntry.path] = oldEntry; + }); + + it('does not revert rename before deleting', done => { + testAction( + deleteEntry, + testEntry.path, + store.state, + [{ type: types.DELETE_ENTRY, payload: testEntry.path }], + [ + { type: 'burstUnusedSeal' }, + { type: 'stageChange', payload: testEntry.path }, + { type: 'triggerFilesChange' }, + ], + done, + ); + }); + + it('when previous is deleted, it reverts rename before deleting', done => { + store.state.entries[testEntry.prevPath].deleted = true; + + testAction( + deleteEntry, + testEntry.path, + store.state, + [], + [ + { + type: 'renameEntry', + payload: { + path: testEntry.path, + name: testEntry.prevName, + parentPath: testEntry.prevParentPath, + }, + }, + { + type: 'deleteEntry', + payload: testEntry.prevPath, + }, + ], + done, + ); + }); + }); + }); + + it('bursts unused seal', done => { + store.state.entries.test = file('test'); + + store + .dispatch('deleteEntry', 'test') + .then(() => { + expect(store.state.unusedSeal).toBe(false); + + done(); + }) + .catch(done.fail); }); }); @@ -724,8 +813,31 @@ describe('Multi-file store actions', () => { }); }); - afterEach(() => { - resetStore(store); + describe('when `gon.feature.stageAllByDefault` is true', () => { + const originalGonFeatures = Object.assign({}, gon.features); + + beforeAll(() => { + gon.features = { stageAllByDefault: true }; + }); + + afterAll(() => { + gon.features = originalGonFeatures; + }); + + it('by default renames an entry and stages it', () => { + const dispatch = jasmine.createSpy(); + const commit = jasmine.createSpy(); + + renameEntry( + { dispatch, commit, state: store.state, getters: store.getters }, + { path: 'orig', name: 'renamed' }, + ); + + expect(commit.calls.allArgs()).toEqual([ + [types.RENAME_ENTRY, { path: 'orig', name: 'renamed', parentPath: undefined }], + [types.STAGE_CHANGE, jasmine.objectContaining({ path: 'renamed' })], + ]); + }); }); it('by default renames an entry and adds to changed', done => { @@ -747,12 +859,12 @@ describe('Multi-file store actions', () => { payload: 'renamed', }, ], - [{ type: 'triggerFilesChange' }], + jasmine.any(Object), done, ); }); - it('if not changed, completely unstages entry if renamed to original', done => { + it('if not changed, completely unstages and discards entry if renamed to original', done => { testAction( renameEntry, { path: 'renamed', name: 'orig' }, @@ -807,6 +919,20 @@ describe('Multi-file store actions', () => { .then(done) .catch(done.fail); }); + + it('bursts unused seal', done => { + store + .dispatch('renameEntry', { + path: 'orig', + name: 'renamed', + }) + .then(() => { + expect(store.state.unusedSeal).toBe(false); + + done(); + }) + .catch(done.fail); + }); }); describe('folder', () => { @@ -908,6 +1034,103 @@ describe('Multi-file store actions', () => { .then(done) .catch(done.fail); }); + + describe('with file in directory', () => { + const parentPath = 'original-dir'; + const newParentPath = 'new-dir'; + const fileName = 'test.md'; + const filePath = `${parentPath}/${fileName}`; + + let rootDir; + + beforeEach(() => { + const parentEntry = file(parentPath, parentPath, 'tree'); + const fileEntry = file(filePath, filePath, 'blob', parentEntry); + rootDir = { + tree: [], + }; + + Object.assign(store.state, { + entries: { + [parentPath]: { + ...parentEntry, + tree: [fileEntry], + }, + [filePath]: fileEntry, + }, + trees: { + '/': rootDir, + }, + }); + }); + + it('creates new directory', done => { + expect(store.state.entries[newParentPath]).toBeUndefined(); + + store + .dispatch('renameEntry', { path: filePath, name: fileName, parentPath: newParentPath }) + .then(() => { + expect(store.state.entries[newParentPath]).toEqual( + jasmine.objectContaining({ + path: newParentPath, + type: 'tree', + tree: jasmine.arrayContaining([ + store.state.entries[`${newParentPath}/${fileName}`], + ]), + }), + ); + }) + .then(done) + .catch(done.fail); + }); + + describe('when new directory exists', () => { + let newDir; + + beforeEach(() => { + newDir = file(newParentPath, newParentPath, 'tree'); + + store.state.entries[newDir.path] = newDir; + rootDir.tree.push(newDir); + }); + + it('inserts in new directory', done => { + expect(newDir.tree).toEqual([]); + + store + .dispatch('renameEntry', { + path: filePath, + name: fileName, + parentPath: newParentPath, + }) + .then(() => { + expect(newDir.tree).toEqual([store.state.entries[`${newParentPath}/${fileName}`]]); + }) + .then(done) + .catch(done.fail); + }); + + it('when new directory is deleted, it undeletes it', done => { + store.dispatch('deleteEntry', newParentPath); + + expect(store.state.entries[newParentPath].deleted).toBe(true); + expect(rootDir.tree.some(x => x.path === newParentPath)).toBe(false); + + store + .dispatch('renameEntry', { + path: filePath, + name: fileName, + parentPath: newParentPath, + }) + .then(() => { + expect(store.state.entries[newParentPath].deleted).toBe(false); + expect(rootDir.tree.some(x => x.path === newParentPath)).toBe(true); + }) + .then(done) + .catch(done.fail); + }); + }); + }); }); }); @@ -924,18 +1147,19 @@ describe('Multi-file store actions', () => { describe('error', () => { let dispatch; - const callParams = [ - { - commit() {}, - state: store.state, - }, - { - projectId: 'abc/def', - branchId: 'master-testing', - }, - ]; + let callParams; beforeEach(() => { + callParams = [ + { + commit() {}, + state: store.state, + }, + { + projectId: 'abc/def', + branchId: 'master-testing', + }, + ]; dispatch = jasmine.createSpy('dispatchSpy'); document.body.innerHTML += '<div class="flash-container"></div>'; }); diff --git a/spec/javascripts/jobs/components/manual_variables_form_spec.js b/spec/javascripts/jobs/components/manual_variables_form_spec.js index 1f2bf8674c1eb8fb31213f6463bf8e00b5b0e6e4..547f146cf88aadddc937a8968477813ebe76e03f 100644 --- a/spec/javascripts/jobs/components/manual_variables_form_spec.js +++ b/spec/javascripts/jobs/components/manual_variables_form_spec.js @@ -20,7 +20,6 @@ describe('Manual Variables Form', () => { wrapper = shallowMount(localVue.extend(Form), { propsData: props, localVue, - sync: false, }); }; diff --git a/spec/javascripts/lib/utils/common_utils_spec.js b/spec/javascripts/lib/utils/common_utils_spec.js index e471be608c8dff7ba24a8edcef99c62c6e12c209..504d4a3e01ad7e322b7e8c4fb9afd458f27fc009 100644 --- a/spec/javascripts/lib/utils/common_utils_spec.js +++ b/spec/javascripts/lib/utils/common_utils_spec.js @@ -1,8 +1,8 @@ import MockAdapter from 'axios-mock-adapter'; +import { GlBreakpointInstance as breakpointInstance } from '@gitlab/ui/dist/utils'; import axios from '~/lib/utils/axios_utils'; import * as commonUtils from '~/lib/utils/common_utils'; import { faviconDataUrl, overlayDataUrl, faviconWithOverlayDataUrl } from './mock_data'; -import breakpointInstance from '~/breakpoints'; const PIXEL_TOLERANCE = 0.2; @@ -88,10 +88,12 @@ describe('common_utils', () => { describe('handleLocationHash', () => { beforeEach(() => { spyOn(window.document, 'getElementById').and.callThrough(); + jasmine.clock().install(); }); afterEach(() => { window.history.pushState({}, null, ''); + jasmine.clock().uninstall(); }); function expectGetElementIdToHaveBeenCalledWith(elementId) { @@ -171,6 +173,7 @@ describe('common_utils', () => { window.history.pushState({}, null, '#test'); commonUtils.handleLocationHash(); + jasmine.clock().tick(1); expectGetElementIdToHaveBeenCalledWith('test'); expectGetElementIdToHaveBeenCalledWith('user-content-test'); diff --git a/spec/javascripts/merge_request_tabs_spec.js b/spec/javascripts/merge_request_tabs_spec.js index 73b1ea4d36f0e7772ac64f579125820d6a5aa9cd..019aa191dc01ccc5d8ede27703fd0519acb27c88 100644 --- a/spec/javascripts/merge_request_tabs_spec.js +++ b/spec/javascripts/merge_request_tabs_spec.js @@ -3,7 +3,6 @@ import MockAdapter from 'axios-mock-adapter'; import axios from '~/lib/utils/axios_utils'; import MergeRequestTabs from '~/merge_request_tabs'; import '~/commit/pipelines/pipelines_bundle'; -import '~/breakpoints'; import '~/lib/utils/common_utils'; import 'vendor/jquery.scrollTo'; import initMrPage from './helpers/init_vue_mr_page_helper'; diff --git a/spec/javascripts/monitoring/components/dashboard_resize_spec.js b/spec/javascripts/monitoring/components/dashboard_resize_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..46a6679da18ffcff8fde5e8912fb26e98fcb83ac --- /dev/null +++ b/spec/javascripts/monitoring/components/dashboard_resize_spec.js @@ -0,0 +1,141 @@ +import Vue from 'vue'; +import { createLocalVue } from '@vue/test-utils'; +import MockAdapter from 'axios-mock-adapter'; +import Dashboard from '~/monitoring/components/dashboard.vue'; +import * as types from '~/monitoring/stores/mutation_types'; +import { createStore } from '~/monitoring/stores'; +import axios from '~/lib/utils/axios_utils'; +import { + metricsGroupsAPIResponse, + mockedEmptyResult, + mockedQueryResultPayload, + mockedQueryResultPayloadCoresTotal, + mockApiEndpoint, + environmentData, +} from '../mock_data'; + +const localVue = createLocalVue(); +const propsData = { + hasMetrics: false, + documentationPath: '/path/to/docs', + settingsPath: '/path/to/settings', + clustersPath: '/path/to/clusters', + tagsPath: '/path/to/tags', + projectPath: '/path/to/project', + defaultBranch: 'master', + metricsEndpoint: mockApiEndpoint, + deploymentsEndpoint: null, + emptyGettingStartedSvgPath: '/path/to/getting-started.svg', + emptyLoadingSvgPath: '/path/to/loading.svg', + emptyNoDataSvgPath: '/path/to/no-data.svg', + emptyNoDataSmallSvgPath: '/path/to/no-data-small.svg', + emptyUnableToConnectSvgPath: '/path/to/unable-to-connect.svg', + environmentsEndpoint: '/root/hello-prometheus/environments/35', + currentEnvironmentName: 'production', + customMetricsAvailable: false, + customMetricsPath: '', + validateQueryPath: '', +}; + +function setupComponentStore(component) { + // Load 2 panel groups + component.$store.commit( + `monitoringDashboard/${types.RECEIVE_METRICS_DATA_SUCCESS}`, + metricsGroupsAPIResponse, + ); + + // Load 3 panels to the dashboard, one with an empty result + component.$store.commit( + `monitoringDashboard/${types.RECEIVE_METRIC_RESULT_SUCCESS}`, + mockedEmptyResult, + ); + component.$store.commit( + `monitoringDashboard/${types.RECEIVE_METRIC_RESULT_SUCCESS}`, + mockedQueryResultPayload, + ); + component.$store.commit( + `monitoringDashboard/${types.RECEIVE_METRIC_RESULT_SUCCESS}`, + mockedQueryResultPayloadCoresTotal, + ); + + component.$store.commit( + `monitoringDashboard/${types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS}`, + environmentData, + ); +} + +describe('Dashboard', () => { + let DashboardComponent; + let mock; + let store; + let component; + let wrapper; + + beforeEach(() => { + setFixtures(` + <div class="prometheus-graphs"></div> + <div class="layout-page"></div> + `); + + store = createStore(); + mock = new MockAdapter(axios); + DashboardComponent = localVue.extend(Dashboard); + }); + + afterEach(() => { + if (component) { + component.$destroy(); + } + if (wrapper) { + wrapper.destroy(); + } + mock.restore(); + }); + + describe('responds to window resizes', () => { + let promPanel; + let promGroup; + let panelToggle; + let chart; + beforeEach(() => { + mock.onGet(mockApiEndpoint).reply(200, metricsGroupsAPIResponse); + + component = new DashboardComponent({ + el: document.querySelector('.prometheus-graphs'), + propsData: { + ...propsData, + hasMetrics: true, + showPanels: true, + }, + store, + }); + + setupComponentStore(component); + + return Vue.nextTick().then(() => { + [, promPanel] = component.$el.querySelectorAll('.prometheus-panel'); + promGroup = promPanel.querySelector('.prometheus-graph-group'); + panelToggle = promPanel.querySelector('.js-graph-group-toggle'); + chart = promGroup.querySelector('.position-relative svg'); + }); + }); + + it('setting chart size to zero when panel group is hidden', () => { + expect(promGroup.style.display).toBe(''); + expect(chart.clientWidth).toBeGreaterThan(0); + + panelToggle.click(); + return Vue.nextTick().then(() => { + expect(promGroup.style.display).toBe('none'); + expect(chart.clientWidth).toBe(0); + promPanel.style.width = '500px'; + }); + }); + + it('expanding chart panel group after resize displays chart', () => { + panelToggle.click(); + + expect(chart.clientWidth).toBeGreaterThan(0); + }); + }); +}); diff --git a/spec/javascripts/monitoring/components/dashboard_spec.js b/spec/javascripts/monitoring/components/dashboard_spec.js deleted file mode 100644 index b29bac218207a09b5cf5f22cc002773c29d92f53..0000000000000000000000000000000000000000 --- a/spec/javascripts/monitoring/components/dashboard_spec.js +++ /dev/null @@ -1,729 +0,0 @@ -import Vue from 'vue'; -import { shallowMount, createLocalVue } from '@vue/test-utils'; -import { GlToast } from '@gitlab/ui'; -import VueDraggable from 'vuedraggable'; -import MockAdapter from 'axios-mock-adapter'; -import Dashboard from '~/monitoring/components/dashboard.vue'; -import { metricStates } from '~/monitoring/constants'; -import GroupEmptyState from '~/monitoring/components/group_empty_state.vue'; -import * as types from '~/monitoring/stores/mutation_types'; -import { createStore } from '~/monitoring/stores'; -import axios from '~/lib/utils/axios_utils'; -import { - metricsGroupsAPIResponse, - mockedEmptyResult, - mockedQueryResultPayload, - mockedQueryResultPayloadCoresTotal, - mockApiEndpoint, - environmentData, - dashboardGitResponse, -} from '../mock_data'; - -const localVue = createLocalVue(); -const propsData = { - hasMetrics: false, - documentationPath: '/path/to/docs', - settingsPath: '/path/to/settings', - clustersPath: '/path/to/clusters', - tagsPath: '/path/to/tags', - projectPath: '/path/to/project', - metricsEndpoint: mockApiEndpoint, - deploymentsEndpoint: null, - emptyGettingStartedSvgPath: '/path/to/getting-started.svg', - emptyLoadingSvgPath: '/path/to/loading.svg', - emptyNoDataSvgPath: '/path/to/no-data.svg', - emptyNoDataSmallSvgPath: '/path/to/no-data-small.svg', - emptyUnableToConnectSvgPath: '/path/to/unable-to-connect.svg', - environmentsEndpoint: '/root/hello-prometheus/environments/35', - currentEnvironmentName: 'production', - customMetricsAvailable: false, - customMetricsPath: '', - validateQueryPath: '', -}; - -const resetSpy = spy => { - if (spy) { - spy.calls.reset(); - } -}; - -let expectedPanelCount; - -function setupComponentStore(component) { - // Load 2 panel groups - component.$store.commit( - `monitoringDashboard/${types.RECEIVE_METRICS_DATA_SUCCESS}`, - metricsGroupsAPIResponse, - ); - - // Load 3 panels to the dashboard, one with an empty result - component.$store.commit( - `monitoringDashboard/${types.RECEIVE_METRIC_RESULT_SUCCESS}`, - mockedEmptyResult, - ); - component.$store.commit( - `monitoringDashboard/${types.RECEIVE_METRIC_RESULT_SUCCESS}`, - mockedQueryResultPayload, - ); - component.$store.commit( - `monitoringDashboard/${types.RECEIVE_METRIC_RESULT_SUCCESS}`, - mockedQueryResultPayloadCoresTotal, - ); - - expectedPanelCount = 2; - - component.$store.commit( - `monitoringDashboard/${types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS}`, - environmentData, - ); -} - -describe('Dashboard', () => { - let DashboardComponent; - let mock; - let store; - let component; - let wrapper; - - const createComponentWrapper = (props = {}, options = {}) => { - wrapper = shallowMount(localVue.extend(DashboardComponent), { - localVue, - sync: false, - propsData: { ...propsData, ...props }, - store, - ...options, - }); - }; - - beforeEach(() => { - setFixtures(` - <div class="prometheus-graphs"></div> - <div class="layout-page"></div> - `); - - store = createStore(); - mock = new MockAdapter(axios); - DashboardComponent = localVue.extend(Dashboard); - }); - - afterEach(() => { - if (component) { - component.$destroy(); - } - if (wrapper) { - wrapper.destroy(); - } - mock.restore(); - }); - - describe('no metrics are available yet', () => { - beforeEach(() => { - component = new DashboardComponent({ - el: document.querySelector('.prometheus-graphs'), - propsData: { ...propsData }, - store, - }); - }); - - it('shows a getting started empty state when no metrics are present', () => { - expect(component.$el.querySelector('.prometheus-graphs')).toBe(null); - expect(component.emptyState).toEqual('gettingStarted'); - }); - - it('shows the environment selector', () => { - expect(component.$el.querySelector('.js-environments-dropdown')).toBeTruthy(); - }); - }); - - describe('no data found', () => { - it('shows the environment selector dropdown', () => { - createComponentWrapper(); - - expect(wrapper.find('.js-environments-dropdown').exists()).toBeTruthy(); - }); - }); - - describe('cluster health', () => { - beforeEach(done => { - createComponentWrapper({ hasMetrics: true }); - - // all_dashboards is not defined in health dashboards - wrapper.vm.$store.commit(`monitoringDashboard/${types.SET_ALL_DASHBOARDS}`, undefined); - wrapper.vm.$nextTick(done); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - it('renders correctly', () => { - expect(wrapper.isVueInstance()).toBe(true); - expect(wrapper.exists()).toBe(true); - }); - }); - - describe('requests information to the server', () => { - let spy; - beforeEach(() => { - mock.onGet(mockApiEndpoint).reply(200, metricsGroupsAPIResponse); - }); - - afterEach(() => { - resetSpy(spy); - }); - - it('shows up a loading state', done => { - component = new DashboardComponent({ - el: document.querySelector('.prometheus-graphs'), - propsData: { ...propsData, hasMetrics: true }, - store, - }); - - Vue.nextTick(() => { - expect(component.emptyState).toEqual('loading'); - done(); - }); - }); - - it('hides the group panels when showPanels is false', done => { - component = new DashboardComponent({ - el: document.querySelector('.prometheus-graphs'), - propsData: { - ...propsData, - hasMetrics: true, - showPanels: false, - }, - store, - }); - - setupComponentStore(component); - - Vue.nextTick() - .then(() => { - expect(component.showEmptyState).toEqual(false); - expect(component.$el.querySelector('.prometheus-panel')).toEqual(null); - expect(component.$el.querySelector('.prometheus-graph-group')).toBeTruthy(); - - done(); - }) - .catch(done.fail); - }); - - describe('when all the requests have been commited by the store', () => { - beforeEach(() => { - component = new DashboardComponent({ - el: document.querySelector('.prometheus-graphs'), - propsData: { - ...propsData, - hasMetrics: true, - }, - store, - }); - - setupComponentStore(component); - }); - - it('renders the environments dropdown with a number of environments', done => { - Vue.nextTick() - .then(() => { - const dropdownMenuEnvironments = component.$el.querySelectorAll( - '.js-environments-dropdown .dropdown-item', - ); - - expect(component.environments.length).toEqual(environmentData.length); - expect(dropdownMenuEnvironments.length).toEqual(component.environments.length); - - Array.from(dropdownMenuEnvironments).forEach((value, index) => { - if (environmentData[index].metrics_path) { - expect(value).toHaveAttr('href', environmentData[index].metrics_path); - } - }); - - done(); - }) - .catch(done.fail); - }); - - it('renders the environments dropdown with a single active element', done => { - Vue.nextTick() - .then(() => { - const dropdownItems = component.$el.querySelectorAll( - '.js-environments-dropdown .dropdown-item.active', - ); - - expect(dropdownItems.length).toEqual(1); - done(); - }) - .catch(done.fail); - }); - }); - - it('hides the environments dropdown list when there is no environments', done => { - component = new DashboardComponent({ - el: document.querySelector('.prometheus-graphs'), - propsData: { - ...propsData, - hasMetrics: true, - }, - store, - }); - - component.$store.commit( - `monitoringDashboard/${types.RECEIVE_METRICS_DATA_SUCCESS}`, - metricsGroupsAPIResponse, - ); - component.$store.commit( - `monitoringDashboard/${types.RECEIVE_METRIC_RESULT_SUCCESS}`, - mockedQueryResultPayload, - ); - - Vue.nextTick() - .then(() => { - const dropdownMenuEnvironments = component.$el.querySelectorAll( - '.js-environments-dropdown .dropdown-item', - ); - - expect(dropdownMenuEnvironments.length).toEqual(0); - done(); - }) - .catch(done.fail); - }); - - it('renders the datetimepicker dropdown', done => { - component = new DashboardComponent({ - el: document.querySelector('.prometheus-graphs'), - propsData: { - ...propsData, - hasMetrics: true, - showPanels: false, - }, - store, - }); - - setupComponentStore(component); - - Vue.nextTick() - .then(() => { - expect(component.$el.querySelector('.js-time-window-dropdown')).not.toBeNull(); - done(); - }) - .catch(done.fail); - }); - - it('fetches the metrics data with proper time window', done => { - component = new DashboardComponent({ - el: document.querySelector('.prometheus-graphs'), - propsData: { - ...propsData, - hasMetrics: true, - showPanels: false, - }, - store, - }); - - spyOn(component.$store, 'dispatch').and.stub(); - const getTimeDiffSpy = spyOnDependency(Dashboard, 'getTimeDiff').and.callThrough(); - - component.$store.commit( - `monitoringDashboard/${types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS}`, - environmentData, - ); - - component.$mount(); - - Vue.nextTick() - .then(() => { - expect(component.$store.dispatch).toHaveBeenCalled(); - expect(getTimeDiffSpy).toHaveBeenCalled(); - - done(); - }) - .catch(done.fail); - }); - - it('shows a specific time window selected from the url params', done => { - const start = '2019-10-01T18:27:47.000Z'; - const end = '2019-10-01T18:57:47.000Z'; - spyOnDependency(Dashboard, 'getTimeDiff').and.returnValue({ - start, - end, - }); - spyOnDependency(Dashboard, 'getParameterValues').and.callFake(param => { - if (param === 'start') return [start]; - if (param === 'end') return [end]; - return []; - }); - - component = new DashboardComponent({ - el: document.querySelector('.prometheus-graphs'), - propsData: { ...propsData, hasMetrics: true }, - store, - sync: false, - }); - - setupComponentStore(component); - - Vue.nextTick() - .then(() => { - const selectedTimeWindow = component.$el.querySelector( - '.js-time-window-dropdown .active', - ); - - expect(selectedTimeWindow.textContent.trim()).toEqual('30 minutes'); - done(); - }) - .catch(done.fail); - }); - - it('shows an error message if invalid url parameters are passed', done => { - spyOnDependency(Dashboard, 'getParameterValues').and.returnValue([ - '<script>alert("XSS")</script>', - ]); - - component = new DashboardComponent({ - el: document.querySelector('.prometheus-graphs'), - propsData: { ...propsData, hasMetrics: true }, - store, - }); - - spy = spyOn(component, 'showInvalidDateError'); - component.$mount(); - - component.$nextTick(() => { - expect(component.showInvalidDateError).toHaveBeenCalled(); - done(); - }); - }); - }); - - describe('when one of the metrics is missing', () => { - beforeEach(() => { - mock.onGet(mockApiEndpoint).reply(200, metricsGroupsAPIResponse); - }); - - beforeEach(done => { - createComponentWrapper({ hasMetrics: true }); - setupComponentStore(wrapper.vm); - - wrapper.vm.$nextTick(done); - }); - - it('shows a group empty area', () => { - const emptyGroup = wrapper.findAll({ ref: 'empty-group' }); - - expect(emptyGroup).toHaveLength(1); - expect(emptyGroup.is(GroupEmptyState)).toBe(true); - }); - - it('group empty area displays a NO_DATA state', () => { - expect( - wrapper - .findAll({ ref: 'empty-group' }) - .at(0) - .props('selectedState'), - ).toEqual(metricStates.NO_DATA); - }); - }); - - describe('drag and drop function', () => { - const findDraggables = () => wrapper.findAll(VueDraggable); - const findEnabledDraggables = () => findDraggables().filter(f => !f.attributes('disabled')); - const findDraggablePanels = () => wrapper.findAll('.js-draggable-panel'); - const findRearrangeButton = () => wrapper.find('.js-rearrange-button'); - - beforeEach(() => { - mock.onGet(mockApiEndpoint).reply(200, metricsGroupsAPIResponse); - }); - - beforeEach(done => { - createComponentWrapper({ hasMetrics: true }, { attachToDocument: true }); - - setupComponentStore(wrapper.vm); - - wrapper.vm.$nextTick(done); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - it('wraps vuedraggable', () => { - expect(findDraggablePanels().exists()).toBe(true); - expect(findDraggablePanels().length).toEqual(expectedPanelCount); - }); - - it('is disabled by default', () => { - expect(findRearrangeButton().exists()).toBe(false); - expect(findEnabledDraggables().length).toBe(0); - }); - - describe('when rearrange is enabled', () => { - beforeEach(done => { - wrapper.setProps({ rearrangePanelsAvailable: true }); - wrapper.vm.$nextTick(done); - }); - - it('displays rearrange button', () => { - expect(findRearrangeButton().exists()).toBe(true); - }); - - describe('when rearrange button is clicked', () => { - const findFirstDraggableRemoveButton = () => - findDraggablePanels() - .at(0) - .find('.js-draggable-remove'); - - beforeEach(done => { - findRearrangeButton().vm.$emit('click'); - wrapper.vm.$nextTick(done); - }); - - it('it enables draggables', () => { - expect(findRearrangeButton().attributes('pressed')).toBeTruthy(); - expect(findEnabledDraggables()).toEqual(findDraggables()); - }); - - it('metrics can be swapped', done => { - const firstDraggable = findDraggables().at(0); - const mockMetrics = [...metricsGroupsAPIResponse[1].panels]; - - const firstTitle = mockMetrics[0].title; - const secondTitle = mockMetrics[1].title; - - // swap two elements and `input` them - [mockMetrics[0], mockMetrics[1]] = [mockMetrics[1], mockMetrics[0]]; - firstDraggable.vm.$emit('input', mockMetrics); - - wrapper.vm.$nextTick(() => { - const { panels } = wrapper.vm.dashboard.panel_groups[1]; - - expect(panels[1].title).toEqual(firstTitle); - expect(panels[0].title).toEqual(secondTitle); - done(); - }); - }); - - it('shows a remove button, which removes a panel', done => { - expect(findFirstDraggableRemoveButton().isEmpty()).toBe(false); - - expect(findDraggablePanels().length).toEqual(expectedPanelCount); - findFirstDraggableRemoveButton().trigger('click'); - - wrapper.vm.$nextTick(() => { - expect(findDraggablePanels().length).toEqual(expectedPanelCount - 1); - done(); - }); - }); - - it('it disables draggables when clicked again', done => { - findRearrangeButton().vm.$emit('click'); - wrapper.vm.$nextTick(() => { - expect(findRearrangeButton().attributes('pressed')).toBeFalsy(); - expect(findEnabledDraggables().length).toBe(0); - done(); - }); - }); - }); - }); - }); - - // https://gitlab.com/gitlab-org/gitlab-ce/issues/66922 - // eslint-disable-next-line jasmine/no-disabled-tests - xdescribe('link to chart', () => { - const currentDashboard = 'TEST_DASHBOARD'; - localVue.use(GlToast); - const link = () => wrapper.find('.js-chart-link'); - const clipboardText = () => link().element.dataset.clipboardText; - - beforeEach(done => { - mock.onGet(mockApiEndpoint).reply(200, metricsGroupsAPIResponse); - - createComponentWrapper({ hasMetrics: true, currentDashboard }, { attachToDocument: true }); - - setTimeout(done); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - it('adds a copy button to the dropdown', () => { - expect(link().text()).toContain('Generate link to chart'); - }); - - it('contains a link to the dashboard', () => { - expect(clipboardText()).toContain(`dashboard=${currentDashboard}`); - expect(clipboardText()).toContain(`group=`); - expect(clipboardText()).toContain(`title=`); - expect(clipboardText()).toContain(`y_label=`); - }); - - it('undefined parameter is stripped', done => { - wrapper.setProps({ currentDashboard: undefined }); - - wrapper.vm.$nextTick(() => { - expect(clipboardText()).not.toContain(`dashboard=`); - expect(clipboardText()).toContain(`y_label=`); - done(); - }); - }); - - it('null parameter is stripped', done => { - wrapper.setProps({ currentDashboard: null }); - - wrapper.vm.$nextTick(() => { - expect(clipboardText()).not.toContain(`dashboard=`); - expect(clipboardText()).toContain(`y_label=`); - done(); - }); - }); - - it('creates a toast when clicked', () => { - spyOn(wrapper.vm.$toast, 'show').and.stub(); - - link().vm.$emit('click'); - - expect(wrapper.vm.$toast.show).toHaveBeenCalled(); - }); - }); - - describe('responds to window resizes', () => { - let promPanel; - let promGroup; - let panelToggle; - let chart; - beforeEach(() => { - mock.onGet(mockApiEndpoint).reply(200, metricsGroupsAPIResponse); - - component = new DashboardComponent({ - el: document.querySelector('.prometheus-graphs'), - propsData: { - ...propsData, - hasMetrics: true, - showPanels: true, - }, - store, - }); - - setupComponentStore(component); - - return Vue.nextTick().then(() => { - [, promPanel] = component.$el.querySelectorAll('.prometheus-panel'); - promGroup = promPanel.querySelector('.prometheus-graph-group'); - panelToggle = promPanel.querySelector('.js-graph-group-toggle'); - chart = promGroup.querySelector('.position-relative svg'); - }); - }); - - it('setting chart size to zero when panel group is hidden', () => { - expect(promGroup.style.display).toBe(''); - expect(chart.clientWidth).toBeGreaterThan(0); - - panelToggle.click(); - return Vue.nextTick().then(() => { - expect(promGroup.style.display).toBe('none'); - expect(chart.clientWidth).toBe(0); - promPanel.style.width = '500px'; - }); - }); - - it('expanding chart panel group after resize displays chart', () => { - panelToggle.click(); - - expect(chart.clientWidth).toBeGreaterThan(0); - }); - }); - - describe('dashboard edit link', () => { - const findEditLink = () => wrapper.find('.js-edit-link'); - - beforeEach(done => { - mock.onGet(mockApiEndpoint).reply(200, metricsGroupsAPIResponse); - - createComponentWrapper({ hasMetrics: true }, { attachToDocument: true }); - - wrapper.vm.$store.commit( - `monitoringDashboard/${types.SET_ALL_DASHBOARDS}`, - dashboardGitResponse, - ); - wrapper.vm.$nextTick(done); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - it('is not present for the default dashboard', () => { - expect(findEditLink().exists()).toBe(false); - }); - - it('is present for a custom dashboard, and links to its edit_path', done => { - const dashboard = dashboardGitResponse[1]; // non-default dashboard - const currentDashboard = dashboard.path; - - wrapper.setProps({ currentDashboard }); - wrapper.vm.$nextTick(() => { - expect(findEditLink().exists()).toBe(true); - expect(findEditLink().attributes('href')).toBe(dashboard.project_blob_path); - done(); - }); - }); - }); - - describe('external dashboard link', () => { - beforeEach(() => { - mock.onGet(mockApiEndpoint).reply(200, metricsGroupsAPIResponse); - - component = new DashboardComponent({ - el: document.querySelector('.prometheus-graphs'), - propsData: { - ...propsData, - hasMetrics: true, - showPanels: false, - showTimeWindowDropdown: false, - externalDashboardUrl: '/mockUrl', - }, - store, - }); - }); - - it('shows the link', done => { - setTimeout(() => { - expect(component.$el.querySelector('.js-external-dashboard-link').innerText).toContain( - 'View full dashboard', - ); - done(); - }); - }); - }); - - describe('Dashboard dropdown', () => { - beforeEach(() => { - mock.onGet(mockApiEndpoint).reply(200, metricsGroupsAPIResponse); - - component = new DashboardComponent({ - el: document.querySelector('.prometheus-graphs'), - propsData: { - ...propsData, - hasMetrics: true, - showPanels: false, - }, - store, - }); - - component.$store.commit( - `monitoringDashboard/${types.SET_ALL_DASHBOARDS}`, - dashboardGitResponse, - ); - }); - - it('shows the dashboard dropdown', done => { - setTimeout(() => { - const dashboardDropdown = component.$el.querySelector('.js-dashboards-dropdown'); - - expect(dashboardDropdown).not.toEqual(null); - done(); - }); - }); - }); -}); diff --git a/spec/javascripts/monitoring/helpers.js b/spec/javascripts/monitoring/helpers.js deleted file mode 100644 index 672e3b948c48d12995304995b6dae960271e2a0a..0000000000000000000000000000000000000000 --- a/spec/javascripts/monitoring/helpers.js +++ /dev/null @@ -1,8 +0,0 @@ -// eslint-disable-next-line import/prefer-default-export -export const resetStore = store => { - store.replaceState({ - showEmptyState: true, - emptyState: 'loading', - groups: [], - }); -}; diff --git a/spec/javascripts/notes/components/discussion_resolve_with_issue_button_spec.js b/spec/javascripts/notes/components/discussion_resolve_with_issue_button_spec.js index 3be1f0be0d0c9070d9631cd55d27fc57531514b4..4348445f7ca929a6089da4af4dffdd72013dfc3e 100644 --- a/spec/javascripts/notes/components/discussion_resolve_with_issue_button_spec.js +++ b/spec/javascripts/notes/components/discussion_resolve_with_issue_button_spec.js @@ -12,7 +12,6 @@ describe('ResolveWithIssueButton', () => { beforeEach(() => { wrapper = shallowMount(ResolveWithIssueButton, { localVue, - sync: false, propsData: { url, }, diff --git a/spec/javascripts/notes/components/note_actions/reply_button_spec.js b/spec/javascripts/notes/components/note_actions/reply_button_spec.js index aa39ab15833e3fd481ffb16f7efdb3e0c408fdbe..720ab10b27002916431883d64a275058895c6f18 100644 --- a/spec/javascripts/notes/components/note_actions/reply_button_spec.js +++ b/spec/javascripts/notes/components/note_actions/reply_button_spec.js @@ -10,7 +10,6 @@ describe('ReplyButton', () => { beforeEach(() => { wrapper = mount(localVue.extend(ReplyButton), { - sync: false, localVue, }); }); diff --git a/spec/javascripts/notes/components/note_actions_spec.js b/spec/javascripts/notes/components/note_actions_spec.js index 2e0694869ba96ac60b9228da10d4e29465357675..259122597fbdde1208fe1f3c1d7adc47bd588b4d 100644 --- a/spec/javascripts/notes/components/note_actions_spec.js +++ b/spec/javascripts/notes/components/note_actions_spec.js @@ -16,7 +16,6 @@ describe('noteActions', () => { store, propsData, localVue, - sync: false, }); }; diff --git a/spec/javascripts/notes/components/note_form_spec.js b/spec/javascripts/notes/components/note_form_spec.js index 35283e14dc5778ca892acedf179c1934d7dde65f..8ab8bce9027097bd24099dfb4339de0a67509075 100644 --- a/spec/javascripts/notes/components/note_form_spec.js +++ b/spec/javascripts/notes/components/note_form_spec.js @@ -19,7 +19,6 @@ describe('issue_note_form component', () => { propsData: props, // see https://gitlab.com/gitlab-org/gitlab-foss/issues/56317 for the following localVue, - sync: false, }); }; diff --git a/spec/javascripts/notes/components/noteable_discussion_spec.js b/spec/javascripts/notes/components/noteable_discussion_spec.js index 5e359759afc28a5d1c5fb7851dfece9c81bf36bd..6efc6485b9c62a1a50e4b68dd6d588754779d933 100644 --- a/spec/javascripts/notes/components/noteable_discussion_spec.js +++ b/spec/javascripts/notes/components/noteable_discussion_spec.js @@ -5,8 +5,15 @@ import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vu import ResolveWithIssueButton from '~/notes/components/discussion_resolve_with_issue_button.vue'; import NoteForm from '~/notes/components/note_form.vue'; import '~/behaviors/markdown/render_gfm'; -import { noteableDataMock, discussionMock, notesDataMock } from '../mock_data'; +import { + noteableDataMock, + discussionMock, + notesDataMock, + loggedOutnoteableData, + userDataMock, +} from '../mock_data'; import mockDiffFile from '../../diffs/mock_data/diff_file'; +import { trimText } from '../../helpers/text_helper'; const discussionWithTwoUnresolvedNotes = 'merge_requests/resolved_diff_discussion.json'; @@ -15,6 +22,7 @@ const localVue = createLocalVue(); describe('noteable_discussion component', () => { let store; let wrapper; + let originalGon; preloadFixtures(discussionWithTwoUnresolvedNotes); @@ -28,7 +36,6 @@ describe('noteable_discussion component', () => { store, propsData: { discussion: discussionMock }, localVue, - sync: false, }); }); @@ -167,4 +174,53 @@ describe('noteable_discussion component', () => { expect(button.exists()).toBe(true); }); }); + + describe('signout widget', () => { + beforeEach(() => { + originalGon = Object.assign({}, window.gon); + window.gon = window.gon || {}; + }); + + afterEach(() => { + wrapper.destroy(); + window.gon = originalGon; + }); + + describe('user is logged in', () => { + beforeEach(() => { + window.gon.current_user_id = userDataMock.id; + store.dispatch('setUserData', userDataMock); + + wrapper = mount(localVue.extend(noteableDiscussion), { + store, + propsData: { discussion: discussionMock }, + localVue, + }); + }); + + it('should not render signed out widget', () => { + expect(Boolean(wrapper.vm.isLoggedIn)).toBe(true); + expect(trimText(wrapper.text())).not.toContain('Please register or sign in to reply'); + }); + }); + + describe('user is not logged in', () => { + beforeEach(() => { + window.gon.current_user_id = null; + store.dispatch('setNoteableData', loggedOutnoteableData); + store.dispatch('setNotesData', notesDataMock); + + wrapper = mount(localVue.extend(noteableDiscussion), { + store, + propsData: { discussion: discussionMock }, + localVue, + }); + }); + + it('should render signed out widget', () => { + expect(Boolean(wrapper.vm.isLoggedIn)).toBe(false); + expect(trimText(wrapper.text())).toContain('Please register or sign in to reply'); + }); + }); + }); }); diff --git a/spec/javascripts/notes/components/noteable_note_spec.js b/spec/javascripts/notes/components/noteable_note_spec.js index 72a13afe49867eddd3e6d9bf71524d0d1c6be796..5fbac7faefdc8ca9854015a16cb94daba15bc913 100644 --- a/spec/javascripts/notes/components/noteable_note_spec.js +++ b/spec/javascripts/notes/components/noteable_note_spec.js @@ -23,7 +23,6 @@ describe('issue_note', () => { propsData: { note, }, - sync: false, localVue, }); }); diff --git a/spec/javascripts/pipelines/graph/graph_component_spec.js b/spec/javascripts/pipelines/graph/graph_component_spec.js index 5effbaabcd1a73339b8da1405260030e08f91053..fa6a5f57410b9b05f14ac092165af9c15d5e2415 100644 --- a/spec/javascripts/pipelines/graph/graph_component_spec.js +++ b/spec/javascripts/pipelines/graph/graph_component_spec.js @@ -190,6 +190,7 @@ describe('graph component', () => { describe('on click', () => { it('should emit `onClickTriggered`', () => { spyOn(component, '$emit'); + spyOn(component, 'calculateMarginTop').and.callFake(() => '16px'); component.$el.querySelector('#js-linked-pipeline-34993051').click(); diff --git a/spec/javascripts/pipelines/graph/linked_pipelines_column_spec.js b/spec/javascripts/pipelines/graph/linked_pipelines_column_spec.js index fe7039da9e4bda92dcccde96f21f5ef738535835..613ab2a906fae8f44e68c65a1e3227a4fcaa736c 100644 --- a/spec/javascripts/pipelines/graph/linked_pipelines_column_spec.js +++ b/spec/javascripts/pipelines/graph/linked_pipelines_column_spec.js @@ -9,6 +9,7 @@ describe('Linked Pipelines Column', () => { columnTitle: 'Upstream', linkedPipelines: mockData.triggered, graphPosition: 'right', + projectId: 19, }; let vm; diff --git a/spec/javascripts/pipelines/header_component_spec.js b/spec/javascripts/pipelines/header_component_spec.js index 556a0976b293be3a724ec3207751af762ef51b00..8c033447ce441bb7d060f66cf9d81ba75eddba31 100644 --- a/spec/javascripts/pipelines/header_component_spec.js +++ b/spec/javascripts/pipelines/header_component_spec.js @@ -34,6 +34,7 @@ describe('Pipeline details header', () => { avatar_url: 'link', }, retry_path: 'path', + delete_path: 'path', }, isLoading: false, }; @@ -55,12 +56,22 @@ describe('Pipeline details header', () => { }); describe('action buttons', () => { - it('should call postAction when button action is clicked', () => { + it('should call postAction when retry button action is clicked', done => { eventHub.$on('headerPostAction', action => { expect(action.path).toEqual('path'); + done(); }); - vm.$el.querySelector('button').click(); + vm.$el.querySelector('.js-retry-button').click(); + }); + + it('should fire modal event when delete button action is clicked', done => { + vm.$root.$on('bv::modal::show', action => { + expect(action.componentId).toEqual('pipeline-delete-modal'); + done(); + }); + + vm.$el.querySelector('.js-btn-delete-pipeline').click(); }); }); }); diff --git a/spec/javascripts/pipelines/linked_pipelines_mock.json b/spec/javascripts/pipelines/linked_pipelines_mock.json index b498903f804ce00c252f4ed029f25695ba32d596..60e214ddc32f4e54bdd15068129872ebf90b787e 100644 --- a/spec/javascripts/pipelines/linked_pipelines_mock.json +++ b/spec/javascripts/pipelines/linked_pipelines_mock.json @@ -341,6 +341,9 @@ "commit_url": "https://gitlab.com/gitlab-org/gitlab-runner/commit/8083eb0a920572214d0dccedd7981f05d535ad46", "commit_path": "/gitlab-org/gitlab-runner/commit/8083eb0a920572214d0dccedd7981f05d535ad46" }, + "project": { + "id": 1794617 + }, "triggered_by": { "id": 12, "user": { diff --git a/spec/javascripts/polyfills/element_spec.js b/spec/javascripts/polyfills/element_spec.js deleted file mode 100644 index d35df595c7298cb0c91b3bc9b66dd61af0c57ec3..0000000000000000000000000000000000000000 --- a/spec/javascripts/polyfills/element_spec.js +++ /dev/null @@ -1,36 +0,0 @@ -import '~/commons/polyfills/element'; - -describe('Element polyfills', function() { - beforeEach(() => { - this.element = document.createElement('ul'); - }); - - describe('matches', () => { - it('returns true if element matches the selector', () => { - expect(this.element.matches('ul')).toBeTruthy(); - }); - - it("returns false if element doesn't match the selector", () => { - expect(this.element.matches('.not-an-element')).toBeFalsy(); - }); - }); - - describe('closest', () => { - beforeEach(() => { - this.childElement = document.createElement('li'); - this.element.appendChild(this.childElement); - }); - - it('returns the closest parent that matches the selector', () => { - expect(this.childElement.closest('ul').toString()).toBe(this.element.toString()); - }); - - it('returns itself if it matches the selector', () => { - expect(this.childElement.closest('li').toString()).toBe(this.childElement.toString()); - }); - - it('returns undefined if nothing matches the selector', () => { - expect(this.childElement.closest('.no-an-element')).toBeFalsy(); - }); - }); -}); diff --git a/spec/javascripts/project_select_combo_button_spec.js b/spec/javascripts/project_select_combo_button_spec.js deleted file mode 100644 index dc85292c23e839b6af3569133c3cda460c0302c5..0000000000000000000000000000000000000000 --- a/spec/javascripts/project_select_combo_button_spec.js +++ /dev/null @@ -1,124 +0,0 @@ -import $ from 'jquery'; -import ProjectSelectComboButton from '~/project_select_combo_button'; - -const fixturePath = 'static/project_select_combo_button.html'; - -describe('Project Select Combo Button', function() { - preloadFixtures(fixturePath); - - beforeEach(function() { - this.defaults = { - label: 'Select project to create issue', - groupId: 12345, - projectMeta: { - name: 'My Cool Project', - url: 'http://mycoolproject.com', - }, - newProjectMeta: { - name: 'My Other Cool Project', - url: 'http://myothercoolproject.com', - }, - localStorageKey: 'group-12345-new-issue-recent-project', - relativePath: 'issues/new', - }; - - loadFixtures(fixturePath); - - this.newItemBtn = document.querySelector('.new-project-item-link'); - this.projectSelectInput = document.querySelector('.project-item-select'); - }); - - describe('on page load when localStorage is empty', function() { - beforeEach(function() { - this.comboButton = new ProjectSelectComboButton(this.projectSelectInput); - }); - - it('newItemBtn href is null', function() { - expect(this.newItemBtn.getAttribute('href')).toBe(''); - }); - - it('newItemBtn text is the plain default label', function() { - expect(this.newItemBtn.textContent).toBe(this.defaults.label); - }); - }); - - describe('on page load when localStorage is filled', function() { - beforeEach(function() { - window.localStorage.setItem( - this.defaults.localStorageKey, - JSON.stringify(this.defaults.projectMeta), - ); - this.comboButton = new ProjectSelectComboButton(this.projectSelectInput); - }); - - it('newItemBtn href is correctly set', function() { - expect(this.newItemBtn.getAttribute('href')).toBe(this.defaults.projectMeta.url); - }); - - it('newItemBtn text is the cached label', function() { - expect(this.newItemBtn.textContent).toBe(`New issue in ${this.defaults.projectMeta.name}`); - }); - - afterEach(function() { - window.localStorage.clear(); - }); - }); - - describe('after selecting a new project', function() { - beforeEach(function() { - this.comboButton = new ProjectSelectComboButton(this.projectSelectInput); - - // mock the effect of selecting an item from the projects dropdown (select2) - $('.project-item-select') - .val(JSON.stringify(this.defaults.newProjectMeta)) - .trigger('change'); - }); - - it('newItemBtn href is correctly set', function() { - expect(this.newItemBtn.getAttribute('href')).toBe('http://myothercoolproject.com/issues/new'); - }); - - it('newItemBtn text is the selected project label', function() { - expect(this.newItemBtn.textContent).toBe(`New issue in ${this.defaults.newProjectMeta.name}`); - }); - - afterEach(function() { - window.localStorage.clear(); - }); - }); - - describe('deriveTextVariants', function() { - beforeEach(function() { - this.mockExecutionContext = { - resourceType: '', - resourceLabel: '', - }; - - this.comboButton = new ProjectSelectComboButton(this.projectSelectInput); - - this.method = this.comboButton.deriveTextVariants.bind(this.mockExecutionContext); - }); - - it('correctly derives test variants for merge requests', function() { - this.mockExecutionContext.resourceType = 'merge_requests'; - this.mockExecutionContext.resourceLabel = 'New merge request'; - - const returnedVariants = this.method(); - - expect(returnedVariants.localStorageItemType).toBe('new-merge-request'); - expect(returnedVariants.defaultTextPrefix).toBe('New merge request'); - expect(returnedVariants.presetTextSuffix).toBe('merge request'); - }); - - it('correctly derives text variants for issues', function() { - this.mockExecutionContext.resourceType = 'issues'; - this.mockExecutionContext.resourceLabel = 'New issue'; - - const returnedVariants = this.method(); - - expect(returnedVariants.localStorageItemType).toBe('new-issue'); - expect(returnedVariants.defaultTextPrefix).toBe('New issue'); - expect(returnedVariants.presetTextSuffix).toBe('issue'); - }); - }); -}); diff --git a/spec/javascripts/projects/project_import_gitlab_project_spec.js b/spec/javascripts/projects/project_import_gitlab_project_spec.js index 126f73103e0820fb7616e6e4285f5fa24f6fd1f3..3c94934699d46582efc212eb6fc6fd9e478ecc95 100644 --- a/spec/javascripts/projects/project_import_gitlab_project_spec.js +++ b/spec/javascripts/projects/project_import_gitlab_project_spec.js @@ -1,25 +1,59 @@ import projectImportGitlab from '~/projects/project_import_gitlab_project'; describe('Import Gitlab project', () => { - let projectName; - beforeEach(() => { - projectName = 'project'; - window.history.pushState({}, null, `?path=${projectName}`); + const pathName = 'my-project'; + const projectName = 'My Project'; + + const setTestFixtures = url => { + window.history.pushState({}, null, url); setFixtures(` <input class="js-path-name" /> + <input class="js-project-name" /> `); projectImportGitlab(); + }; + + beforeEach(() => { + setTestFixtures(`?name=${projectName}&path=${pathName}`); }); afterEach(() => { window.history.pushState({}, null, ''); }); - describe('path name', () => { + describe('project name', () => { it('should fill in the project name derived from the previously filled project name', () => { - expect(document.querySelector('.js-path-name').value).toEqual(projectName); + expect(document.querySelector('.js-project-name').value).toEqual(projectName); + }); + + describe('empty path name', () => { + it('derives the path name from the previously filled project name', () => { + const alternateProjectName = 'My Alt Project'; + const alternatePathName = 'my-alt-project'; + + setTestFixtures(`?name=${alternateProjectName}`); + + expect(document.querySelector('.js-path-name').value).toEqual(alternatePathName); + }); + }); + }); + + describe('path name', () => { + it('should fill in the path name derived from the previously filled path name', () => { + expect(document.querySelector('.js-path-name').value).toEqual(pathName); + }); + + describe('empty project name', () => { + it('derives the project name from the previously filled path name', () => { + const alternateProjectName = 'My Alt Project'; + const alternatePathName = 'my-alt-project'; + + setTestFixtures(`?path=${alternatePathName}`); + + expect(document.querySelector('.js-project-name').value).toEqual(alternateProjectName); + }); }); }); }); diff --git a/spec/javascripts/projects/project_new_spec.js b/spec/javascripts/projects/project_new_spec.js index 106a3ba94e47bff7bbcaf2d4245e40c9e508b8a6..7c6ff90aff696691bcb10fd6aed427cd5881b125 100644 --- a/spec/javascripts/projects/project_new_spec.js +++ b/spec/javascripts/projects/project_new_spec.js @@ -172,4 +172,34 @@ describe('New Project', () => { expect($projectPath.val()).toEqual('my-dash-delimited-awesome-project'); }); }); + + describe('derivesProjectNameFromSlug', () => { + const dummyProjectPath = 'my-awesome-project'; + const dummyProjectName = 'Original Awesome Project'; + + beforeEach(() => { + projectNew.bindEvents(); + $projectPath.val('').change(); + }); + + it('converts slug to humanized project name', () => { + $projectPath.val(dummyProjectPath); + + projectNew.onProjectPathChange($projectName, $projectPath); + + expect($projectName.val()).toEqual('My Awesome Project'); + }); + + it('does not convert slug to humanized project name if a project name already exists', () => { + $projectName.val(dummyProjectName); + $projectPath.val(dummyProjectPath); + projectNew.onProjectPathChange( + $projectName, + $projectPath, + $projectName.val().trim().length > 0, + ); + + expect($projectName.val()).toEqual(dummyProjectName); + }); + }); }); diff --git a/spec/javascripts/related_merge_requests/components/related_merge_requests_spec.js b/spec/javascripts/related_merge_requests/components/related_merge_requests_spec.js index cf3ab4d4a682ba84dedb0c220c5766bee2d5849d..d8bdf69dfee88413b01c0597e855cad1fa012bb3 100644 --- a/spec/javascripts/related_merge_requests/components/related_merge_requests_spec.js +++ b/spec/javascripts/related_merge_requests/components/related_merge_requests_spec.js @@ -22,7 +22,6 @@ describe('RelatedMergeRequests', () => { wrapper = mount(localVue.extend(RelatedMergeRequests), { localVue, - sync: false, store: createStore(), propsData: { endpoint: API_ENDPOINT, diff --git a/spec/javascripts/sidebar/participants_spec.js b/spec/javascripts/sidebar/participants_spec.js index 8d8ec5884f642f83adcfb9183e555765665923a8..7e80e86f8ca52ee55c8984bccb25e14fa314c0cb 100644 --- a/spec/javascripts/sidebar/participants_spec.js +++ b/spec/javascripts/sidebar/participants_spec.js @@ -182,4 +182,21 @@ describe('Participants', function() { expect(vm.$emit).toHaveBeenCalledWith('toggleSidebar'); }); }); + + describe('when not showing participants label', () => { + beforeEach(() => { + vm = mountComponent(Participants, { + participants: PARTICIPANT_LIST, + showParticipantLabel: false, + }); + }); + + it('does not show sidebar collapsed icon', () => { + expect(vm.$el.querySelector('.sidebar-collapsed-icon')).not.toBeTruthy(); + }); + + it('does not show participants label title', () => { + expect(vm.$el.querySelector('.title')).not.toBeTruthy(); + }); + }); }); diff --git a/spec/javascripts/sidebar/sidebar_store_spec.js b/spec/javascripts/sidebar/sidebar_store_spec.js deleted file mode 100644 index 85ff70fffbd425b473c5bbea5d72ea18db1b7b5a..0000000000000000000000000000000000000000 --- a/spec/javascripts/sidebar/sidebar_store_spec.js +++ /dev/null @@ -1,162 +0,0 @@ -import SidebarStore from '~/sidebar/stores/sidebar_store'; -import Mock from './mock_data'; -import UsersMockHelper from '../helpers/user_mock_data_helper'; - -const ASSIGNEE = { - id: 2, - name: 'gitlab user 2', - username: 'gitlab2', - avatar_url: 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', -}; - -const ANOTHER_ASSINEE = { - id: 3, - name: 'gitlab user 3', - username: 'gitlab3', - avatar_url: 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', -}; - -const PARTICIPANT = { - id: 1, - state: 'active', - username: 'marcene', - name: 'Allie Will', - web_url: 'foo.com', - avatar_url: 'gravatar.com/avatar/xxx', -}; - -const PARTICIPANT_LIST = [PARTICIPANT, { ...PARTICIPANT, id: 2 }, { ...PARTICIPANT, id: 3 }]; - -describe('Sidebar store', function() { - beforeEach(() => { - this.store = new SidebarStore({ - currentUser: { - id: 1, - name: 'Administrator', - username: 'root', - avatar_url: - 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', - }, - editable: true, - rootPath: '/', - endpoint: '/gitlab-org/gitlab-shell/issues/5.json', - }); - }); - - afterEach(() => { - SidebarStore.singleton = null; - }); - - it('has default isFetching values', () => { - expect(this.store.isFetching.assignees).toBe(true); - }); - - it('adds a new assignee', () => { - this.store.addAssignee(ASSIGNEE); - - expect(this.store.assignees.length).toEqual(1); - }); - - it('removes an assignee', () => { - this.store.removeAssignee(ASSIGNEE); - - expect(this.store.assignees.length).toEqual(0); - }); - - it('finds an existent assignee', () => { - let foundAssignee; - - this.store.addAssignee(ASSIGNEE); - foundAssignee = this.store.findAssignee(ASSIGNEE); - - expect(foundAssignee).toBeDefined(); - expect(foundAssignee).toEqual(ASSIGNEE); - foundAssignee = this.store.findAssignee(ANOTHER_ASSINEE); - - expect(foundAssignee).toBeUndefined(); - }); - - it('removes all assignees', () => { - this.store.removeAllAssignees(); - - expect(this.store.assignees.length).toEqual(0); - }); - - it('sets participants data', () => { - expect(this.store.participants.length).toEqual(0); - - this.store.setParticipantsData({ - participants: PARTICIPANT_LIST, - }); - - expect(this.store.isFetching.participants).toEqual(false); - expect(this.store.participants.length).toEqual(PARTICIPANT_LIST.length); - }); - - it('sets subcriptions data', () => { - expect(this.store.subscribed).toEqual(null); - - this.store.setSubscriptionsData({ - subscribed: true, - }); - - expect(this.store.isFetching.subscriptions).toEqual(false); - expect(this.store.subscribed).toEqual(true); - }); - - it('set assigned data', () => { - const users = { - assignees: UsersMockHelper.createNumberRandomUsers(3), - }; - - this.store.setAssigneeData(users); - - expect(this.store.isFetching.assignees).toBe(false); - expect(this.store.assignees.length).toEqual(3); - }); - - it('sets fetching state', () => { - expect(this.store.isFetching.participants).toEqual(true); - - this.store.setFetchingState('participants', false); - - expect(this.store.isFetching.participants).toEqual(false); - }); - - it('sets loading state', () => { - this.store.setLoadingState('assignees', true); - - expect(this.store.isLoading.assignees).toEqual(true); - }); - - it('set time tracking data', () => { - this.store.setTimeTrackingData(Mock.time); - - expect(this.store.timeEstimate).toEqual(Mock.time.time_estimate); - expect(this.store.totalTimeSpent).toEqual(Mock.time.total_time_spent); - expect(this.store.humanTimeEstimate).toEqual(Mock.time.human_time_estimate); - expect(this.store.humanTotalTimeSpent).toEqual(Mock.time.human_total_time_spent); - }); - - it('set autocomplete projects', () => { - const projects = [{ id: 0 }]; - this.store.setAutocompleteProjects(projects); - - expect(this.store.autocompleteProjects).toEqual(projects); - }); - - it('sets subscribed state', () => { - expect(this.store.subscribed).toEqual(null); - - this.store.setSubscribedState(true); - - expect(this.store.subscribed).toEqual(true); - }); - - it('set move to project ID', () => { - const projectId = 7; - this.store.setMoveToProjectId(projectId); - - expect(this.store.moveToProjectId).toEqual(projectId); - }); -}); diff --git a/spec/javascripts/version_check_image_spec.js b/spec/javascripts/version_check_image_spec.js deleted file mode 100644 index 0e69fcc4c5ff65e84dfcaa7fb7ec7d546701758c..0000000000000000000000000000000000000000 --- a/spec/javascripts/version_check_image_spec.js +++ /dev/null @@ -1,35 +0,0 @@ -import $ from 'jquery'; -import VersionCheckImage from '~/version_check_image'; -import ClassSpecHelper from './helpers/class_spec_helper'; - -describe('VersionCheckImage', function() { - describe('bindErrorEvent', function() { - ClassSpecHelper.itShouldBeAStaticMethod(VersionCheckImage, 'bindErrorEvent'); - - beforeEach(function() { - this.imageElement = $('<div></div>'); - }); - - it('registers an error event', function() { - spyOn($.prototype, 'on'); - spyOn($.prototype, 'off').and.callFake(function() { - return this; - }); - - VersionCheckImage.bindErrorEvent(this.imageElement); - - expect($.prototype.off).toHaveBeenCalledWith('error'); - expect($.prototype.on).toHaveBeenCalledWith('error', jasmine.any(Function)); - }); - - it('hides the imageElement on error', function() { - spyOn($.prototype, 'hide'); - - VersionCheckImage.bindErrorEvent(this.imageElement); - - this.imageElement.trigger('error'); - - expect($.prototype.hide).toHaveBeenCalled(); - }); - }); -}); diff --git a/spec/javascripts/vue_mr_widget/components/mr_widget_alert_message_spec.js b/spec/javascripts/vue_mr_widget/components/mr_widget_alert_message_spec.js index bd567f1f93a05dff517982f91cca015161961b9f..f78fcfb52b4c78437ae7461287daae09a25cd877 100644 --- a/spec/javascripts/vue_mr_widget/components/mr_widget_alert_message_spec.js +++ b/spec/javascripts/vue_mr_widget/components/mr_widget_alert_message_spec.js @@ -11,7 +11,6 @@ describe('MrWidgetAlertMessage', () => { wrapper = shallowMount(localVue.extend(MrWidgetAlertMessage), { propsData: {}, localVue, - sync: false, }); }); 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 index 2d6d22d66aae0ff469853192cbaccc072c08d9b8..76827cde093cafd7c45d5cdd5bdb7753029e24d8 100644 --- 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 @@ -16,7 +16,6 @@ describe('MrWidgetPipelineContainer', () => { ...props, }, localVue, - sync: false, }); }; diff --git a/spec/javascripts/vue_mr_widget/components/review_app_link_spec.js b/spec/javascripts/vue_mr_widget/components/review_app_link_spec.js index bd481f93413ed9902b08bd0f3ced670c1c3819f4..242193c7b3dbf41ff0659102e8cb3daae54daefb 100644 --- a/spec/javascripts/vue_mr_widget/components/review_app_link_spec.js +++ b/spec/javascripts/vue_mr_widget/components/review_app_link_spec.js @@ -8,7 +8,10 @@ describe('review app link', () => { const props = { link: '/review', cssClass: 'js-link', - isCurrent: true, + display: { + text: 'View app', + tooltip: '', + }, }; let vm; let el; diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js index a2fa098bf6bff3deb4eb9cffbc043b60dccf9638..6c44ffc6ec93e7ccdcaa5c93bf5ca0333a9dac8b 100644 --- a/spec/javascripts/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js +++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js @@ -634,20 +634,18 @@ describe('ReadyToMerge', () => { }); describe('when user can merge and can delete branch', () => { - let customVm; - beforeEach(() => { - customVm = createComponent({ + vm = createComponent({ mr: { canRemoveSourceBranch: true }, }); }); it('isRemoveSourceBranchButtonDisabled should be false', () => { - expect(customVm.isRemoveSourceBranchButtonDisabled).toBe(false); + expect(vm.isRemoveSourceBranchButtonDisabled).toBe(false); }); - it('should be enabled in rendered output', () => { - const checkboxElement = customVm.$el.querySelector('#remove-source-branch-input'); + it('removed source branch should be enabled in rendered output', () => { + const checkboxElement = vm.$el.querySelector('#remove-source-branch-input'); expect(checkboxElement).not.toBeNull(); }); @@ -926,22 +924,36 @@ describe('ReadyToMerge', () => { }); describe('Commit message area', () => { - it('when using merge commits, should show "Modify commit message" button', () => { - const customVm = createComponent({ - mr: { ffOnlyEnabled: false }, + describe('when using merge commits', () => { + beforeEach(() => { + vm = createComponent({ + mr: { ffOnlyEnabled: false }, + }); + }); + + it('should not show fast forward message', () => { + expect(vm.$el.querySelector('.mr-fast-forward-message')).toBeNull(); }); - expect(customVm.$el.querySelector('.mr-fast-forward-message')).toBeNull(); - expect(customVm.$el.querySelector('.js-modify-commit-message-button')).toBeDefined(); + it('should show "Modify commit message" button', () => { + expect(vm.$el.querySelector('.js-modify-commit-message-button')).toBeDefined(); + }); }); - it('when fast-forward merge is enabled, only show fast-forward message', () => { - const customVm = createComponent({ - mr: { ffOnlyEnabled: true }, + describe('when fast-forward merge is enabled', () => { + beforeEach(() => { + vm = createComponent({ + mr: { ffOnlyEnabled: true }, + }); + }); + + it('should show fast forward message', () => { + expect(vm.$el.querySelector('.mr-fast-forward-message')).toBeDefined(); }); - expect(customVm.$el.querySelector('.mr-fast-forward-message')).toBeDefined(); - expect(customVm.$el.querySelector('.js-modify-commit-message-button')).toBeNull(); + it('should not show "Modify commit message" button', () => { + expect(vm.$el.querySelector('.js-modify-commit-message-button')).toBeNull(); + }); }); }); diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_squash_before_merge_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_squash_before_merge_spec.js index cb656525f06704488316aea0640bcc0c1d1ed7fd..b70d580ed041a03890b92efa3b4441576c4ec15f 100644 --- a/spec/javascripts/vue_mr_widget/components/states/mr_widget_squash_before_merge_spec.js +++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_squash_before_merge_spec.js @@ -9,7 +9,6 @@ describe('Squash before merge component', () => { const createComponent = props => { wrapper = shallowMount(localVue.extend(SquashBeforeMerge), { localVue, - sync: false, propsData: { ...props, }, diff --git a/spec/javascripts/vue_shared/components/header_ci_component_spec.js b/spec/javascripts/vue_shared/components/header_ci_component_spec.js index 7bd5e5a64b1ab8886c4a9b4c1a4c97351753280a..ea2eed2886afb4caea9f298b2ab8a767a8154c12 100644 --- a/spec/javascripts/vue_shared/components/header_ci_component_spec.js +++ b/spec/javascripts/vue_shared/components/header_ci_component_spec.js @@ -31,17 +31,9 @@ describe('Header CI Component', () => { { label: 'Retry', path: 'path', - type: 'button', cssClass: 'btn', isLoading: false, }, - { - label: 'Go', - path: 'path', - type: 'link', - cssClass: 'link', - isLoading: false, - }, ], hasSidebarButton: true, }; @@ -77,11 +69,10 @@ describe('Header CI Component', () => { }); it('should render provided actions', () => { - expect(vm.$el.querySelector('.btn').tagName).toEqual('BUTTON'); - expect(vm.$el.querySelector('.btn').textContent.trim()).toEqual(props.actions[0].label); - expect(vm.$el.querySelector('.link').tagName).toEqual('A'); - expect(vm.$el.querySelector('.link').textContent.trim()).toEqual(props.actions[1].label); - expect(vm.$el.querySelector('.link').getAttribute('href')).toEqual(props.actions[0].path); + const btn = vm.$el.querySelector('.btn'); + + expect(btn.tagName).toEqual('BUTTON'); + expect(btn.textContent.trim()).toEqual(props.actions[0].label); }); it('should show loading icon', done => { diff --git a/spec/javascripts/vue_shared/components/issue/related_issuable_mock_data.js b/spec/javascripts/vue_shared/components/issue/related_issuable_mock_data.js index 26bfdd7551e3498b9ca9c797b550401ec4869d4c..92080cb9bd58ac14d3d795371dc14f841e8aa95c 100644 --- a/spec/javascripts/vue_shared/components/issue/related_issuable_mock_data.js +++ b/spec/javascripts/vue_shared/components/issue/related_issuable_mock_data.js @@ -6,40 +6,43 @@ export const defaultProps = { export const issuable1 = { id: 200, - epic_issue_id: 1, + epicIssueId: 1, confidential: false, reference: 'foo/bar#123', displayReference: '#123', title: 'some title', path: '/foo/bar/issues/123', state: 'opened', + linkType: 'relates_to', }; export const issuable2 = { id: 201, - epic_issue_id: 2, + epicIssueId: 2, confidential: false, reference: 'foo/bar#124', displayReference: '#124', title: 'some other thing', path: '/foo/bar/issues/124', state: 'opened', + linkType: 'blocks', }; export const issuable3 = { id: 202, - epic_issue_id: 3, + epicIssueId: 3, confidential: false, reference: 'foo/bar#125', displayReference: '#125', title: 'some other other thing', path: '/foo/bar/issues/125', state: 'opened', + linkType: 'is_blocked_by', }; export const issuable4 = { id: 203, - epic_issue_id: 4, + epicIssueId: 4, confidential: false, reference: 'foo/bar#126', displayReference: '#126', @@ -50,7 +53,7 @@ export const issuable4 = { export const issuable5 = { id: 204, - epic_issue_id: 5, + epicIssueId: 5, confidential: false, reference: 'foo/bar#127', displayReference: '#127', diff --git a/spec/javascripts/vue_shared/components/loading_button_spec.js b/spec/javascripts/vue_shared/components/loading_button_spec.js deleted file mode 100644 index 6b03c354e018deb478eceb015580695586364caa..0000000000000000000000000000000000000000 --- a/spec/javascripts/vue_shared/components/loading_button_spec.js +++ /dev/null @@ -1,111 +0,0 @@ -import Vue from 'vue'; -import mountComponent from 'spec/helpers/vue_mount_component_helper'; -import loadingButton from '~/vue_shared/components/loading_button.vue'; - -const LABEL = 'Hello'; - -describe('LoadingButton', function() { - let vm; - let LoadingButton; - - beforeEach(() => { - LoadingButton = Vue.extend(loadingButton); - }); - - afterEach(() => { - vm.$destroy(); - }); - - describe('loading spinner', () => { - it('shown when loading', () => { - vm = mountComponent(LoadingButton, { - loading: true, - }); - - expect(vm.$el.querySelector('.js-loading-button-icon')).toBeDefined(); - }); - }); - - describe('disabled state', () => { - it('disabled when loading', () => { - vm = mountComponent(LoadingButton, { - loading: true, - }); - - expect(vm.$el.disabled).toEqual(true); - }); - - it('not disabled when normal', () => { - vm = mountComponent(LoadingButton, { - loading: false, - }); - - expect(vm.$el.disabled).toEqual(false); - }); - }); - - describe('label', () => { - it('shown when normal', () => { - vm = mountComponent(LoadingButton, { - loading: false, - label: LABEL, - }); - const label = vm.$el.querySelector('.js-loading-button-label'); - - expect(label.textContent.trim()).toEqual(LABEL); - }); - - it('shown when loading', () => { - vm = mountComponent(LoadingButton, { - loading: true, - label: LABEL, - }); - const label = vm.$el.querySelector('.js-loading-button-label'); - - expect(label.textContent.trim()).toEqual(LABEL); - }); - }); - - describe('container class', () => { - it('should default to btn btn-align-content', () => { - vm = mountComponent(LoadingButton, {}); - - expect(vm.$el.classList.contains('btn')).toEqual(true); - expect(vm.$el.classList.contains('btn-align-content')).toEqual(true); - }); - - it('should be configurable through props', () => { - vm = mountComponent(LoadingButton, { - containerClass: 'test-class', - }); - - expect(vm.$el.classList.contains('btn')).toEqual(false); - expect(vm.$el.classList.contains('btn-align-content')).toEqual(false); - expect(vm.$el.classList.contains('test-class')).toEqual(true); - }); - }); - - describe('click callback prop', () => { - it('calls given callback when normal', () => { - vm = mountComponent(LoadingButton, { - loading: false, - }); - spyOn(vm, '$emit'); - - vm.$el.click(); - - expect(vm.$emit).toHaveBeenCalledWith('click', jasmine.any(Object)); - }); - - it('does not call given callback when disabled because of loading', () => { - vm = mountComponent(LoadingButton, { - loading: true, - }); - spyOn(vm, '$emit'); - - vm.$el.click(); - - expect(vm.$emit).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/spec/javascripts/vue_shared/components/pagination/graphql_pagination_spec.js b/spec/javascripts/vue_shared/components/pagination/graphql_pagination_spec.js index 204c0decfd84b205d322873627a69a57a5166cb2..9e72a0e24804c358aed359fe7330e55259d3a7ed 100644 --- a/spec/javascripts/vue_shared/components/pagination/graphql_pagination_spec.js +++ b/spec/javascripts/vue_shared/components/pagination/graphql_pagination_spec.js @@ -11,7 +11,6 @@ describe('Graphql Pagination component', () => { hasNextPage, hasPreviousPage, }, - sync: false, localVue, }); } diff --git a/spec/javascripts/vue_shared/components/project_selector/project_list_item_spec.js b/spec/javascripts/vue_shared/components/project_selector/project_list_item_spec.js index 271ae1b645f78941601a3d72834c23c567947953..e73fb97b741b9fcef07e66f8d4c370aaa7df7174 100644 --- a/spec/javascripts/vue_shared/components/project_selector/project_list_item_spec.js +++ b/spec/javascripts/vue_shared/components/project_selector/project_list_item_spec.js @@ -18,7 +18,6 @@ describe('ProjectListItem component', () => { project, selected: false, }, - sync: false, localVue, }; }); diff --git a/spec/javascripts/vue_shared/components/project_selector/project_selector_spec.js b/spec/javascripts/vue_shared/components/project_selector/project_selector_spec.js index 6815da31436af490c3abc4920c4d43095b352d3d..2b60ea0fd7462b73ad3ee4f5d3ef6bf2dabebc69 100644 --- a/spec/javascripts/vue_shared/components/project_selector/project_selector_spec.js +++ b/spec/javascripts/vue_shared/components/project_selector/project_selector_spec.js @@ -33,7 +33,6 @@ describe('ProjectSelector component', () => { showLoadingIndicator: false, showSearchErrorMessage: false, }, - sync: false, attachToDocument: true, }); diff --git a/spec/javascripts/vue_shared/components/tooltip_on_truncate_spec.js b/spec/javascripts/vue_shared/components/tooltip_on_truncate_spec.js index ad8d5a5329186af196af52a8269eb2d539ab2ecf..a8d39b7b5fea64992f14bf864d4214470e5bffae 100644 --- a/spec/javascripts/vue_shared/components/tooltip_on_truncate_spec.js +++ b/spec/javascripts/vue_shared/components/tooltip_on_truncate_spec.js @@ -15,7 +15,6 @@ describe('TooltipOnTruncate component', () => { const createComponent = ({ propsData, ...options } = {}) => { wrapper = shallowMount(localVue.extend(TooltipOnTruncate), { localVue, - sync: false, attachToDocument: true, propsData: { title: TEST_TITLE, diff --git a/spec/lib/api/helpers/pagination_spec.rb b/spec/lib/api/helpers/pagination_spec.rb index 2d5bec2e752bfbfd7fef6639cdbe0e92704bc2ff..796c753d6c4e4317b379ee501c90e2f1cd8d3aea 100644 --- a/spec/lib/api/helpers/pagination_spec.rb +++ b/spec/lib/api/helpers/pagination_spec.rb @@ -5,70 +5,14 @@ require 'spec_helper' describe API::Helpers::Pagination do subject { Class.new.include(described_class).new } - let(:expected_result) { double("result", to_a: double) } - let(:relation) { double("relation") } - let(:params) { {} } + let(:paginator) { double('paginator') } + let(:relation) { double('relation') } + let(:expected_result) { double('expected result') } - before do - allow(subject).to receive(:params).and_return(params) - end - - describe '#paginate' do - let(:offset_pagination) { double("offset pagination") } - - it 'delegates to OffsetPagination' do - expect(::Gitlab::Pagination::OffsetPagination).to receive(:new).with(subject).and_return(offset_pagination) - expect(offset_pagination).to receive(:paginate).with(relation).and_return(expected_result) - - result = subject.paginate(relation) - - expect(result).to eq(expected_result) - end - end - - describe '#paginate_and_retrieve!' do - context 'for offset pagination' do - before do - allow(Gitlab::Pagination::Keyset).to receive(:available?).and_return(false) - end - - it 'delegates to paginate' do - expect(subject).to receive(:paginate).with(relation).and_return(expected_result) - - result = subject.paginate_and_retrieve!(relation) - - expect(result).to eq(expected_result.to_a) - end - end - - context 'for keyset pagination' do - let(:params) { { pagination: 'keyset' } } - let(:request_context) { double('request context') } - - before do - allow(Gitlab::Pagination::Keyset::RequestContext).to receive(:new).with(subject).and_return(request_context) - end - - context 'when keyset pagination is available' do - it 'delegates to KeysetPagination' do - expect(Gitlab::Pagination::Keyset).to receive(:available?).and_return(true) - expect(Gitlab::Pagination::Keyset).to receive(:paginate).with(request_context, relation).and_return(expected_result) - - result = subject.paginate_and_retrieve!(relation) - - expect(result).to eq(expected_result.to_a) - end - end - - context 'when keyset pagination is not available' do - it 'renders a 501 error if keyset pagination isnt available yet' do - expect(Gitlab::Pagination::Keyset).to receive(:available?).with(request_context, relation).and_return(false) - expect(Gitlab::Pagination::Keyset).not_to receive(:paginate) - expect(subject).to receive(:error!).with(/not yet available/, 405) + it 'delegates to OffsetPagination' do + expect(Gitlab::Pagination::OffsetPagination).to receive(:new).with(subject).and_return(paginator) + expect(paginator).to receive(:paginate).with(relation).and_return(expected_result) - subject.paginate_and_retrieve!(relation) - end - end - end + expect(subject.paginate(relation)).to eq(expected_result) end end diff --git a/spec/lib/api/helpers/pagination_strategies_spec.rb b/spec/lib/api/helpers/pagination_strategies_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..a418c09a82499ecaad4928f8cf41559a83294f93 --- /dev/null +++ b/spec/lib/api/helpers/pagination_strategies_spec.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe API::Helpers::PaginationStrategies do + subject { Class.new.include(described_class).new } + + let(:expected_result) { double("result") } + let(:relation) { double("relation") } + let(:params) { {} } + + before do + allow(subject).to receive(:params).and_return(params) + end + + describe '#paginate_with_strategies' do + let(:paginator) { double("paginator", paginate: expected_result, finalize: nil) } + + before do + allow(subject).to receive(:paginator).with(relation).and_return(paginator) + end + + it 'yields paginated relation' do + expect { |b| subject.paginate_with_strategies(relation, &b) }.to yield_with_args(expected_result) + end + + it 'calls #finalize with first value returned from block' do + return_value = double + expect(paginator).to receive(:finalize).with(return_value) + + subject.paginate_with_strategies(relation) do |records| + some_options = {} + [return_value, some_options] + end + end + + it 'returns whatever the block returns' do + return_value = [double, double] + + result = subject.paginate_with_strategies(relation) do |records| + return_value + end + + expect(result).to eq(return_value) + end + end + + describe '#paginator' do + context 'offset pagination' do + let(:paginator) { double("paginator") } + + before do + allow(subject).to receive(:keyset_pagination_enabled?).and_return(false) + end + + it 'delegates to OffsetPagination' do + expect(Gitlab::Pagination::OffsetPagination).to receive(:new).with(subject).and_return(paginator) + + expect(subject.paginator(relation)).to eq(paginator) + end + end + + context 'for keyset pagination' do + let(:params) { { pagination: 'keyset' } } + let(:request_context) { double('request context') } + let(:pager) { double('pager') } + + before do + allow(subject).to receive(:keyset_pagination_enabled?).and_return(true) + allow(Gitlab::Pagination::Keyset::RequestContext).to receive(:new).with(subject).and_return(request_context) + end + + context 'when keyset pagination is available' do + before do + allow(Gitlab::Pagination::Keyset).to receive(:available?).and_return(true) + allow(Gitlab::Pagination::Keyset::Pager).to receive(:new).with(request_context).and_return(pager) + end + + it 'delegates to Pager' do + expect(subject.paginator(relation)).to eq(pager) + end + end + + context 'when keyset pagination is not available' do + before do + allow(Gitlab::Pagination::Keyset).to receive(:available?).with(request_context, relation).and_return(false) + end + + it 'renders a 501 error' do + expect(subject).to receive(:error!).with(/not yet available/, 405) + + subject.paginator(relation) + end + end + end + end +end diff --git a/spec/lib/banzai/filter/abstract_reference_filter_spec.rb b/spec/lib/banzai/filter/abstract_reference_filter_spec.rb index 3e8b0ea113f06b4378fb1f1adec509bcf292648a..798112d0f5379929fc4aa6912d8cd4d187b4b2b9 100644 --- a/spec/lib/banzai/filter/abstract_reference_filter_spec.rb +++ b/spec/lib/banzai/filter/abstract_reference_filter_spec.rb @@ -3,30 +3,27 @@ require 'spec_helper' describe Banzai::Filter::AbstractReferenceFilter do - let(:project) { create(:project) } + let_it_be(:project) { create(:project) } + + let(:doc) { Nokogiri::HTML.fragment('') } + let(:filter) { described_class.new(doc, project: project) } describe '#references_per_parent' do - it 'returns a Hash containing references grouped per parent paths' do - doc = Nokogiri::HTML.fragment("#1 #{project.full_path}#2") - filter = described_class.new(doc, project: project) + let(:doc) { Nokogiri::HTML.fragment("#1 #{project.full_path}#2 #2") } - expect(filter).to receive(:object_class).exactly(4).times.and_return(Issue) - expect(filter).to receive(:object_sym).twice.and_return(:issue) + it 'returns a Hash containing references grouped per parent paths' do + expect(described_class).to receive(:object_class).exactly(6).times.and_return(Issue) refs = filter.references_per_parent - expect(refs).to be_an_instance_of(Hash) - expect(refs[project.full_path]).to eq(Set.new(%w[1 2])) + expect(refs).to match(a_hash_including(project.full_path => contain_exactly(1, 2))) end end describe '#parent_per_reference' do it 'returns a Hash containing projects grouped per parent paths' do - doc = Nokogiri::HTML.fragment('') - filter = described_class.new(doc, project: project) - expect(filter).to receive(:references_per_parent) - .and_return({ project.full_path => Set.new(%w[1]) }) + .and_return({ project.full_path => Set.new([1]) }) expect(filter.parent_per_reference) .to eq({ project.full_path => project }) @@ -34,9 +31,6 @@ describe Banzai::Filter::AbstractReferenceFilter do end describe '#find_for_paths' do - let(:doc) { Nokogiri::HTML.fragment('') } - let(:filter) { described_class.new(doc, project: project) } - context 'with RequestStore disabled' do it 'returns a list of Projects for a list of paths' do expect(filter.find_for_paths([project.full_path])) diff --git a/spec/lib/banzai/filter/plantuml_filter_spec.rb b/spec/lib/banzai/filter/plantuml_filter_spec.rb index 713bab4527b282ee0dcd28d518a68dffbb5c11e6..abe525ac47a02d260590778bcf52b7f2de67d931 100644 --- a/spec/lib/banzai/filter/plantuml_filter_spec.rb +++ b/spec/lib/banzai/filter/plantuml_filter_spec.rb @@ -26,7 +26,7 @@ describe Banzai::Filter::PlantumlFilter do it 'does not replace plantuml pre tag with img tag if url is invalid' do stub_application_setting(plantuml_enabled: true, plantuml_url: "invalid") input = '<pre><code lang="plantuml">Bob -> Sara : Hello</code></pre>' - output = '<div class="listingblock"><div class="content"><pre class="plantuml plantuml-error"> PlantUML Error: cannot connect to PlantUML server at "invalid"</pre></div></div>' + output = '<div class="listingblock"><div class="content"><pre class="plantuml plantuml-error"> Error: cannot connect to PlantUML server at "invalid"</pre></div></div>' doc = filter(input) expect(doc.to_s).to eq output diff --git a/spec/lib/banzai/filter/relative_link_filter_spec.rb b/spec/lib/banzai/filter/repository_link_filter_spec.rb similarity index 66% rename from spec/lib/banzai/filter/relative_link_filter_spec.rb rename to spec/lib/banzai/filter/repository_link_filter_spec.rb index 9f467d7a6fd6174420a9b64d3959a43f7219ca2f..c87f452a3dfd3b464fb84de2ed9f43f7970a2ac5 100644 --- a/spec/lib/banzai/filter/relative_link_filter_spec.rb +++ b/spec/lib/banzai/filter/repository_link_filter_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -describe Banzai::Filter::RelativeLinkFilter do +describe Banzai::Filter::RepositoryLinkFilter do include GitHelpers include RepoHelpers @@ -128,11 +128,6 @@ describe Banzai::Filter::RelativeLinkFilter do expect { filter(act) }.not_to raise_error end - it 'does not raise an exception on URIs containing invalid utf-8 byte sequences in uploads' do - act = link("/uploads/%FF") - expect { filter(act) }.not_to raise_error - end - it 'does not raise an exception on URIs containing invalid utf-8 byte sequences in context requested path' do expect { filter(link("files/test.md"), requested_path: '%FF') }.not_to raise_error end @@ -147,11 +142,6 @@ describe Banzai::Filter::RelativeLinkFilter do expect { filter(act) }.not_to raise_error end - it 'does not raise an exception with a space in the path' do - act = link("/uploads/d18213acd3732630991986120e167e3d/Landscape_8.jpg \nBut here's some more unexpected text :smile:)") - expect { filter(act) }.not_to raise_error - end - it 'ignores ref if commit is passed' do doc = filter(link('non/existent.file'), commit: project.commit('empty-branch') ) expect(doc.at_css('a')['href']) @@ -350,166 +340,4 @@ describe Banzai::Filter::RelativeLinkFilter do include_examples :valid_repository end - - context 'with a /upload/ URL' do - # not needed - let(:commit) { nil } - let(:ref) { nil } - let(:requested_path) { nil } - let(:upload_path) { '/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg' } - let(:relative_path) { "/#{project.full_path}#{upload_path}" } - - context 'to a project upload' do - shared_examples 'rewrite project uploads' do - context 'with an absolute URL' do - let(:absolute_path) { Gitlab.config.gitlab.url + relative_path } - let(:only_path) { false } - - it 'rewrites the link correctly' do - doc = filter(link(upload_path)) - - expect(doc.at_css('a')['href']).to eq(absolute_path) - end - end - - it 'rebuilds relative URL for a link' do - doc = filter(link(upload_path)) - expect(doc.at_css('a')['href']).to eq(relative_path) - - doc = filter(nested(link(upload_path))) - expect(doc.at_css('a')['href']).to eq(relative_path) - end - - it 'rebuilds relative URL for an image' do - doc = filter(image(upload_path)) - expect(doc.at_css('img')['src']).to eq(relative_path) - - doc = filter(nested(image(upload_path))) - expect(doc.at_css('img')['src']).to eq(relative_path) - end - - it 'does not modify absolute URL' do - doc = filter(link('http://example.com')) - expect(doc.at_css('a')['href']).to eq 'http://example.com' - end - - it 'supports unescaped Unicode filenames' do - path = '/uploads/한글.png' - doc = filter(link(path)) - - expect(doc.at_css('a')['href']).to eq("/#{project.full_path}/uploads/%ED%95%9C%EA%B8%80.png") - end - - it 'supports escaped Unicode filenames' do - path = '/uploads/한글.png' - escaped = Addressable::URI.escape(path) - doc = filter(image(escaped)) - - expect(doc.at_css('img')['src']).to eq("/#{project.full_path}/uploads/%ED%95%9C%EA%B8%80.png") - end - end - - context 'without project repository access' do - let(:project) { create(:project, :repository, repository_access_level: ProjectFeature::PRIVATE) } - - it_behaves_like 'rewrite project uploads' - end - - context 'with project repository access' do - it_behaves_like 'rewrite project uploads' - end - end - - context 'to a group upload' do - let(:upload_link) { link('/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg') } - let(:group) { create(:group) } - let(:project) { nil } - let(:relative_path) { "/groups/#{group.full_path}/-/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg" } - - context 'with an absolute URL' do - let(:absolute_path) { Gitlab.config.gitlab.url + relative_path } - let(:only_path) { false } - - it 'rewrites the link correctly' do - doc = filter(upload_link) - - expect(doc.at_css('a')['href']).to eq(absolute_path) - end - end - - it 'rewrites the link correctly' do - doc = filter(upload_link) - - expect(doc.at_css('a')['href']).to eq(relative_path) - end - - it 'rewrites the link correctly for subgroup' do - group.update!(parent: create(:group)) - - doc = filter(upload_link) - - expect(doc.at_css('a')['href']).to eq(relative_path) - end - - it 'does not modify absolute URL' do - doc = filter(link('http://example.com')) - - expect(doc.at_css('a')['href']).to eq 'http://example.com' - end - end - - context 'to a personal snippet' do - let(:group) { nil } - let(:project) { nil } - let(:relative_path) { '/uploads/-/system/personal_snippet/6/674e4f07fbf0a7736c3439212896e51a/example.tar.gz' } - - context 'with an absolute URL' do - let(:absolute_path) { Gitlab.config.gitlab.url + relative_path } - let(:only_path) { false } - - it 'rewrites the link correctly' do - doc = filter(link(relative_path)) - - expect(doc.at_css('a')['href']).to eq(absolute_path) - end - end - - context 'with a relative URL root' do - let(:gitlab_root) { '/gitlab' } - let(:absolute_path) { Gitlab.config.gitlab.url + gitlab_root + relative_path } - - before do - stub_config_setting(relative_url_root: gitlab_root) - end - - context 'with an absolute URL' do - let(:only_path) { false } - - it 'rewrites the link correctly' do - doc = filter(link(relative_path)) - - expect(doc.at_css('a')['href']).to eq(absolute_path) - end - end - - it 'rewrites the link correctly' do - doc = filter(link(relative_path)) - - expect(doc.at_css('a')['href']).to eq(gitlab_root + relative_path) - end - end - - it 'rewrites the link correctly' do - doc = filter(link(relative_path)) - - expect(doc.at_css('a')['href']).to eq(relative_path) - end - - it 'does not modify absolute URL' do - doc = filter(link('http://example.com')) - - expect(doc.at_css('a')['href']).to eq 'http://example.com' - end - end - end end diff --git a/spec/lib/banzai/filter/upload_link_filter_spec.rb b/spec/lib/banzai/filter/upload_link_filter_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..3f181dce7bceceb6d9858064423066ca01cf882b --- /dev/null +++ b/spec/lib/banzai/filter/upload_link_filter_spec.rb @@ -0,0 +1,221 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Banzai::Filter::UploadLinkFilter do + def filter(doc, contexts = {}) + contexts.reverse_merge!( + project: project, + group: group, + only_path: only_path + ) + + described_class.call(doc, contexts) + end + + def image(path) + %(<img src="#{path}" />) + end + + def video(path) + %(<video src="#{path}"></video>) + end + + def audio(path) + %(<audio src="#{path}"></audio>) + end + + def link(path) + %(<a href="#{path}">#{path}</a>) + end + + def nested(element) + %(<div>#{element}</div>) + end + + let_it_be(:project) { create(:project, :public) } + let_it_be(:user) { create(:user) } + let(:group) { nil } + let(:project_path) { project.full_path } + let(:only_path) { true } + let(:upload_path) { '/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg' } + let(:relative_path) { "/#{project.full_path}#{upload_path}" } + + context 'to a project upload' do + context 'with an absolute URL' do + let(:absolute_path) { Gitlab.config.gitlab.url + relative_path } + let(:only_path) { false } + + it 'rewrites the link correctly' do + doc = filter(link(upload_path)) + + expect(doc.at_css('a')['href']).to eq(absolute_path) + expect(doc.at_css('a').classes).to include('gfm') + end + end + + it 'rebuilds relative URL for a link' do + doc = filter(link(upload_path)) + + expect(doc.at_css('a')['href']).to eq(relative_path) + expect(doc.at_css('a').classes).to include('gfm') + + doc = filter(nested(link(upload_path))) + + expect(doc.at_css('a')['href']).to eq(relative_path) + expect(doc.at_css('a').classes).to include('gfm') + end + + it 'rebuilds relative URL for an image' do + doc = filter(image(upload_path)) + + expect(doc.at_css('img')['src']).to eq(relative_path) + expect(doc.at_css('img').classes).to include('gfm') + + doc = filter(nested(image(upload_path))) + + expect(doc.at_css('img')['src']).to eq(relative_path) + expect(doc.at_css('img').classes).to include('gfm') + end + + it 'does not modify absolute URL' do + doc = filter(link('http://example.com')) + + expect(doc.at_css('a')['href']).to eq 'http://example.com' + expect(doc.at_css('a').classes).not_to include('gfm') + end + + it 'supports unescaped Unicode filenames' do + path = '/uploads/한글.png' + doc = filter(link(path)) + + expect(doc.at_css('a')['href']).to eq("/#{project.full_path}/uploads/%ED%95%9C%EA%B8%80.png") + expect(doc.at_css('a').classes).to include('gfm') + end + + it 'supports escaped Unicode filenames' do + path = '/uploads/한글.png' + escaped = Addressable::URI.escape(path) + doc = filter(image(escaped)) + + expect(doc.at_css('img')['src']).to eq("/#{project.full_path}/uploads/%ED%95%9C%EA%B8%80.png") + expect(doc.at_css('img').classes).to include('gfm') + end + end + + context 'to a group upload' do + let(:upload_link) { link('/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg') } + let_it_be(:group) { create(:group) } + let(:project) { nil } + let(:relative_path) { "/groups/#{group.full_path}/-/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg" } + + context 'with an absolute URL' do + let(:absolute_path) { Gitlab.config.gitlab.url + relative_path } + let(:only_path) { false } + + it 'rewrites the link correctly' do + doc = filter(upload_link) + + expect(doc.at_css('a')['href']).to eq(absolute_path) + expect(doc.at_css('a').classes).to include('gfm') + end + end + + it 'rewrites the link correctly' do + doc = filter(upload_link) + + expect(doc.at_css('a')['href']).to eq(relative_path) + expect(doc.at_css('a').classes).to include('gfm') + end + + it 'rewrites the link correctly for subgroup' do + group.update!(parent: create(:group)) + + doc = filter(upload_link) + + expect(doc.at_css('a')['href']).to eq(relative_path) + expect(doc.at_css('a').classes).to include('gfm') + end + + it 'does not modify absolute URL' do + doc = filter(link('http://example.com')) + + expect(doc.at_css('a')['href']).to eq 'http://example.com' + expect(doc.at_css('a').classes).not_to include('gfm') + end + end + + context 'to a personal snippet' do + let(:group) { nil } + let(:project) { nil } + let(:relative_path) { '/uploads/-/system/personal_snippet/6/674e4f07fbf0a7736c3439212896e51a/example.tar.gz' } + + context 'with an absolute URL' do + let(:absolute_path) { Gitlab.config.gitlab.url + relative_path } + let(:only_path) { false } + + it 'rewrites the link correctly' do + doc = filter(link(relative_path)) + + expect(doc.at_css('a')['href']).to eq(absolute_path) + expect(doc.at_css('a').classes).to include('gfm') + end + end + + context 'with a relative URL root' do + let(:gitlab_root) { '/gitlab' } + let(:absolute_path) { Gitlab.config.gitlab.url + gitlab_root + relative_path } + + before do + stub_config_setting(relative_url_root: gitlab_root) + end + + context 'with an absolute URL' do + let(:only_path) { false } + + it 'rewrites the link correctly' do + doc = filter(link(relative_path)) + + expect(doc.at_css('a')['href']).to eq(absolute_path) + expect(doc.at_css('a').classes).to include('gfm') + end + end + + it 'rewrites the link correctly' do + doc = filter(link(relative_path)) + + expect(doc.at_css('a')['href']).to eq(gitlab_root + relative_path) + expect(doc.at_css('a').classes).to include('gfm') + end + end + + it 'rewrites the link correctly' do + doc = filter(link(relative_path)) + + expect(doc.at_css('a')['href']).to eq(relative_path) + expect(doc.at_css('a').classes).to include('gfm') + end + + it 'does not modify absolute URL' do + doc = filter(link('http://example.com')) + + expect(doc.at_css('a')['href']).to eq 'http://example.com' + expect(doc.at_css('a').classes).not_to include('gfm') + end + end + + context 'invalid input' do + using RSpec::Parameterized::TableSyntax + + where(:name, :href) do + 'invalid URI' | '://foo' + 'invalid UTF-8 byte sequences' | '%FF' + 'garbled path' | 'open(/var/tmp/):%20/location%0Afrom:%20/test' + 'whitespace' | "d18213acd3732630991986120e167e3d/Landscape_8.jpg\nand more" + end + + with_them do + it { expect { filter(link("/uploads/#{href}")) }.not_to raise_error } + end + end +end diff --git a/spec/lib/banzai/pipeline/post_process_pipeline_spec.rb b/spec/lib/banzai/pipeline/post_process_pipeline_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..ab72354edcf08866393e941b3326bb60d59bacbd --- /dev/null +++ b/spec/lib/banzai/pipeline/post_process_pipeline_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Banzai::Pipeline::PostProcessPipeline do + context 'when a document only has upload links' do + it 'does not make any Gitaly calls', :request_store do + markdown = <<-MARKDOWN.strip_heredoc + [Relative Upload Link](/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg) + +  + MARKDOWN + + context = { + project: create(:project, :public, :repository), + ref: 'master' + } + + Gitlab::GitalyClient.reset_counts + + described_class.call(markdown, context) + + expect(Gitlab::GitalyClient.get_request_count).to eq(0) + end + end +end diff --git a/spec/lib/banzai/pipeline/wiki_pipeline_spec.rb b/spec/lib/banzai/pipeline/wiki_pipeline_spec.rb index e1814ea403e29a89ec21e959e8b260073a0426cf..8c009bc409b4788630da2504b86a32687311a707 100644 --- a/spec/lib/banzai/pipeline/wiki_pipeline_spec.rb +++ b/spec/lib/banzai/pipeline/wiki_pipeline_spec.rb @@ -230,7 +230,7 @@ describe Banzai::Pipeline::WikiPipeline do ] invalid_slugs.each do |slug| - context "with the invalid slug #{slug}" do + context "with the invalid slug #{slug.delete("\000")}" do invalid_js_links.each do |link| it "doesn't include a prohibited slug in a (.) relative link '#{link}'" do output = described_class.to_html( diff --git a/spec/lib/banzai/reference_parser/base_parser_spec.rb b/spec/lib/banzai/reference_parser/base_parser_spec.rb index 7897164d985290c3b20f8171ae3a55706b568c2d..b1002c1db254688c2f8da09bd4a712232e1dbd0d 100644 --- a/spec/lib/banzai/reference_parser/base_parser_spec.rb +++ b/spec/lib/banzai/reference_parser/base_parser_spec.rb @@ -312,6 +312,12 @@ describe Banzai::ReferenceParser::BaseParser do expect(subject.collection_objects_for_ids(Project, [project.id])) .to eq([project]) end + + it 'will not overflow the stack' do + ids = 1.upto(1_000_000).to_a + + expect { subject.collection_objects_for_ids(User, ids) }.not_to raise_error + end end end diff --git a/spec/lib/expand_variables_spec.rb b/spec/lib/expand_variables_spec.rb index 394efa85701269c3f05057f1b75c6e65c9529fa5..1b8ec2b197932e9c697734256ad963db6be7c615 100644 --- a/spec/lib/expand_variables_spec.rb +++ b/spec/lib/expand_variables_spec.rb @@ -100,7 +100,7 @@ describe ExpandVariables do end with_them do - subject { ExpandVariables.expand(value, variables) } # rubocop:disable RSpec/DescribedClass + subject { ExpandVariables.expand(value, variables) } it { is_expected.to eq(result) } end diff --git a/spec/lib/feature_spec.rb b/spec/lib/feature_spec.rb index 3d59b1f35a98a979cd83b45f115ef14257f0d325..2525dd17b895b2829a3c46c922019cc355e41fee 100644 --- a/spec/lib/feature_spec.rb +++ b/spec/lib/feature_spec.rb @@ -171,6 +171,13 @@ describe Feature do end end + it 'returns the default value when the database does not exist' do + fake_default = double('fake default') + expect(ActiveRecord::Base).to receive(:connection) { raise ActiveRecord::NoDatabaseError, "No database" } + + expect(described_class.enabled?(:a_feature, default_enabled: fake_default)).to eq(fake_default) + end + context 'cached feature flag', :request_store do let(:flag) { :some_feature_flag } diff --git a/spec/lib/gitlab/app_json_logger_spec.rb b/spec/lib/gitlab/app_json_logger_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..22a398f8bca8f920752add8b34606946d6d846e6 --- /dev/null +++ b/spec/lib/gitlab/app_json_logger_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::AppJsonLogger do + subject { described_class.new('/dev/null') } + + let(:hash_message) { { 'message' => 'Message', 'project_id' => '123' } } + let(:string_message) { 'Information' } + + it 'logs a hash as a JSON' do + expect(JSON.parse(subject.format_message('INFO', Time.now, nil, hash_message))).to include(hash_message) + end + + it 'logs a string as a JSON' do + expect(JSON.parse(subject.format_message('INFO', Time.now, nil, string_message))).to include('message' => string_message) + end +end diff --git a/spec/lib/gitlab/app_logger_spec.rb b/spec/lib/gitlab/app_logger_spec.rb index 3b21104b15d89aadae063add8d40ca94dbe49958..132a10b94095171b0e24dc4e799de804f390d29d 100644 --- a/spec/lib/gitlab/app_logger_spec.rb +++ b/spec/lib/gitlab/app_logger_spec.rb @@ -2,13 +2,21 @@ require 'spec_helper' -describe Gitlab::AppLogger, :request_store do +describe Gitlab::AppLogger do subject { described_class } - it 'builds a logger once' do - expect(::Logger).to receive(:new).and_call_original + it 'builds a Gitlab::Logger object twice' do + expect(Gitlab::Logger).to receive(:new) + .exactly(described_class.loggers.size) + .and_call_original - subject.info('hello world') - subject.error('hello again') + subject.info('Hello World!') + end + + it 'logs info to AppLogger and AppJsonLogger' do + expect_any_instance_of(Gitlab::AppTextLogger).to receive(:info).and_call_original + expect_any_instance_of(Gitlab::AppJsonLogger).to receive(:info).and_call_original + + subject.info('Hello World!') end end diff --git a/spec/lib/gitlab/app_text_logger_spec.rb b/spec/lib/gitlab/app_text_logger_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..c84b986ce40f43ea2654e5cf967923ebe61d0c93 --- /dev/null +++ b/spec/lib/gitlab/app_text_logger_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::AppTextLogger do + subject { described_class.new('/dev/null') } + + let(:hash_message) { { message: 'Message', project_id: 123 } } + let(:string_message) { 'Information' } + + it 'logs a hash as string' do + expect(subject.format_message('INFO', Time.now, nil, hash_message )).to include(hash_message.to_s) + end + + it 'logs a string unchanged' do + expect(subject.format_message('INFO', Time.now, nil, string_message)).to include(string_message) + end + + it 'logs time in UTC with ISO8601.3 standard' do + Timecop.freeze do + expect(subject.format_message('INFO', Time.now, nil, string_message)) + .to include(Time.now.utc.iso8601(3)) + end + end +end diff --git a/spec/lib/gitlab/application_context_spec.rb b/spec/lib/gitlab/application_context_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..482bf0dc192d117afb6d7be4cf16bc9cf9a25e1e --- /dev/null +++ b/spec/lib/gitlab/application_context_spec.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::ApplicationContext do + describe '.with_context' do + it 'yields the block' do + expect { |b| described_class.with_context({}, &b) }.to yield_control + end + + it 'passes the expected context on to labkit' do + fake_proc = duck_type(:call) + expected_context = hash_including(user: fake_proc, project: fake_proc, root_namespace: fake_proc) + + expect(Labkit::Context).to receive(:with_context).with(expected_context) + + described_class.with_context( + user: build(:user), + project: build(:project), + namespace: build(:namespace)) {} + end + + it 'raises an error when passing invalid options' do + expect { described_class.with_context(no: 'option') {} }.to raise_error(ArgumentError) + end + end + + describe '.push' do + it 'passes the expected context on to labkit' do + fake_proc = duck_type(:call) + expected_context = { user: fake_proc } + + expect(Labkit::Context).to receive(:push).with(expected_context) + + described_class.push(user: build(:user)) + end + + it 'raises an error when passing invalid options' do + expect { described_class.push(no: 'option')}.to raise_error(ArgumentError) + end + end + + describe '#to_lazy_hash' do + let(:user) { build(:user) } + let(:project) { build(:project) } + let(:namespace) { build(:group) } + let(:subgroup) { build(:group, parent: namespace) } + + def result(context) + context.to_lazy_hash.transform_values { |v| v.call } + end + + it 'does not call the attributes until needed' do + fake_proc = double('Proc') + + expect(fake_proc).not_to receive(:call) + + described_class.new(user: fake_proc, project: fake_proc, namespace: fake_proc).to_lazy_hash + end + + it 'correctly loads the expected values when they are wrapped in a block' do + context = described_class.new(user: -> { user }, project: -> { project }, namespace: -> { subgroup }) + + expect(result(context)) + .to include(user: user.username, project: project.full_path, root_namespace: namespace.full_path) + end + + it 'correctly loads the expected values when passed directly' do + context = described_class.new(user: user, project: project, namespace: subgroup) + + expect(result(context)) + .to include(user: user.username, project: project.full_path, root_namespace: namespace.full_path) + end + + it 'falls back to a projects namespace when a project is passed but no namespace' do + context = described_class.new(project: project) + + expect(result(context)) + .to include(project: project.full_path, root_namespace: project.full_path_components.first) + end + + context 'only include values for which an option was specified' do + using RSpec::Parameterized::TableSyntax + + where(:provided_options, :expected_context_keys) do + [:user, :namespace, :project] | [:user, :project, :root_namespace] + [:user, :project] | [:user, :project, :root_namespace] + [:user, :namespace] | [:user, :root_namespace] + [:user] | [:user] + [] | [] + end + + with_them do + it do + # Build a hash that has all `provided_options` as keys, and `nil` as value + provided_values = provided_options.map { |key| [key, nil] }.to_h + context = described_class.new(provided_values) + + expect(context.to_lazy_hash.keys).to contain_exactly(*expected_context_keys) + end + end + end + end +end diff --git a/spec/lib/gitlab/asciidoc_spec.rb b/spec/lib/gitlab/asciidoc_spec.rb index 38ec04ebe81a0621d5977d33fe2b565cf354ed43..c8d159d1e846c777e6145226920b0a74e12c1884 100644 --- a/spec/lib/gitlab/asciidoc_spec.rb +++ b/spec/lib/gitlab/asciidoc_spec.rb @@ -481,7 +481,6 @@ module Gitlab ['../sample.adoc', 'doc/sample.adoc', 'relative path to a file up one directory'], ['../../sample.adoc', 'sample.adoc', 'relative path for a file up multiple directories'] ].each do |include_path_, file_path_, desc| - context "the file is specified by #{desc}" do let(:include_path) { include_path_ } let(:file_path) { file_path_ } @@ -519,6 +518,28 @@ module Gitlab end end + context 'when repository is passed into the context' do + let(:wiki_repo) { project.wiki.repository } + let(:include_path) { 'wiki_file.adoc' } + + before do + project.create_wiki + context.merge!(repository: wiki_repo) + end + + context 'when the file exists' do + before do + create_file(include_path, 'Content from wiki', repository: wiki_repo) + end + + it { is_expected.to include('<p>Content from wiki</p>') } + end + + context 'when the file does not exist' do + it { is_expected.to include("[ERROR: include::#{include_path}[] - unresolved directive]")} + end + end + context 'recursive includes with relative paths' do let(:input) do <<~ADOC @@ -563,8 +584,8 @@ module Gitlab end end - def create_file(path, content) - project.repository.create_file(project.creator, path, content, + def create_file(path, content, repository: project.repository) + repository.create_file(project.creator, path, content, message: "Add #{path}", branch_name: 'asciidoc') end end diff --git a/spec/lib/gitlab/auth/auth_finders_spec.rb b/spec/lib/gitlab/auth/auth_finders_spec.rb index 82ff8e7f76cd4b96a576c1877983aae932020c28..bffaaef4ed4905bf8370db1a7910d3abdd693772 100644 --- a/spec/lib/gitlab/auth/auth_finders_spec.rb +++ b/spec/lib/gitlab/auth/auth_finders_spec.rb @@ -446,6 +446,93 @@ describe Gitlab::Auth::AuthFinders do end end + describe '#find_user_from_job_token' do + let(:job) { create(:ci_build, user: user) } + let(:route_authentication_setting) { { job_token_allowed: true } } + + subject { find_user_from_job_token } + + context 'when the job token is in the headers' do + it 'returns the user if valid job token' do + env[described_class::JOB_TOKEN_HEADER] = job.token + + is_expected.to eq(user) + expect(@current_authenticated_job).to eq(job) + end + + it 'returns nil without job token' do + env[described_class::JOB_TOKEN_HEADER] = '' + + is_expected.to be_nil + end + + it 'returns exception if invalid job token' do + env[described_class::JOB_TOKEN_HEADER] = 'invalid token' + + expect { subject }.to raise_error(Gitlab::Auth::UnauthorizedError) + end + + context 'when route is not allowed to be authenticated' do + let(:route_authentication_setting) { { job_token_allowed: false } } + + it 'sets current_user to nil' do + env[described_class::JOB_TOKEN_HEADER] = job.token + + allow_any_instance_of(Gitlab::UserAccess).to receive(:allowed?).and_return(true) + + is_expected.to be_nil + end + end + end + + context 'when the job token is in the params' do + shared_examples 'job token params' do |token_key_name| + before do + set_param(token_key_name, token) + end + + context 'with valid job token' do + let(:token) { job.token } + + it 'returns the user' do + is_expected.to eq(user) + expect(@current_authenticated_job).to eq(job) + end + end + + context 'with empty job token' do + let(:token) { '' } + + it 'returns nil' do + is_expected.to be_nil + end + end + + context 'with invalid job token' do + let(:token) { 'invalid token' } + + it 'returns exception' do + expect { subject }.to raise_error(Gitlab::Auth::UnauthorizedError) + end + end + + context 'when route is not allowed to be authenticated' do + let(:route_authentication_setting) { { job_token_allowed: false } } + let(:token) { job.token } + + it 'sets current_user to nil' do + allow_any_instance_of(Gitlab::UserAccess).to receive(:allowed?).and_return(true) + + is_expected.to be_nil + end + end + end + + it_behaves_like 'job token params', described_class::JOB_TOKEN_PARAM + it_behaves_like 'job token params', described_class::RUNNER_JOB_TOKEN_PARAM + end + end + describe '#find_runner_from_token' do let(:runner) { create(:ci_runner) } diff --git a/spec/lib/gitlab/auth/request_authenticator_spec.rb b/spec/lib/gitlab/auth/request_authenticator_spec.rb index 4dbcd0df3022a4d186774e13b18275b05e8f72f3..87c96803c3ad37513790d280b68a6253eaa72229 100644 --- a/spec/lib/gitlab/auth/request_authenticator_spec.rb +++ b/spec/lib/gitlab/auth/request_authenticator_spec.rb @@ -42,6 +42,8 @@ describe Gitlab::Auth::RequestAuthenticator do describe '#find_sessionless_user' do let!(:access_token_user) { build(:user) } let!(:feed_token_user) { build(:user) } + let!(:static_object_token_user) { build(:user) } + let!(:job_token_user) { build(:user) } it 'returns access_token user first' do allow_any_instance_of(described_class).to receive(:find_user_from_web_access_token).and_return(access_token_user) @@ -56,6 +58,22 @@ describe Gitlab::Auth::RequestAuthenticator do expect(subject.find_sessionless_user([:api])).to eq feed_token_user end + it 'returns static_object_token user if no feed_token user found' do + allow_any_instance_of(described_class) + .to receive(:find_user_from_static_object_token) + .and_return(static_object_token_user) + + expect(subject.find_sessionless_user([:api])).to eq static_object_token_user + end + + it 'returns job_token user if no static_object_token user found' do + allow_any_instance_of(described_class) + .to receive(:find_user_from_job_token) + .and_return(job_token_user) + + expect(subject.find_sessionless_user([:api])).to eq job_token_user + end + it 'returns nil if no user found' do expect(subject.find_sessionless_user([:api])).to be_blank end @@ -67,6 +85,39 @@ describe Gitlab::Auth::RequestAuthenticator do end end + describe '#find_user_from_job_token' do + let!(:user) { build(:user) } + let!(:job) { build(:ci_build, user: user) } + + before do + env[Gitlab::Auth::AuthFinders::JOB_TOKEN_HEADER] = 'token' + end + + context 'with API requests' do + before do + env['SCRIPT_NAME'] = '/api/endpoint' + end + + it 'tries to find the user' do + expect(::Ci::Build).to receive(:find_by_token).and_return(job) + + expect(subject.find_sessionless_user([:api])).to eq user + end + end + + context 'without API requests' do + before do + env['SCRIPT_NAME'] = '/web/endpoint' + end + + it 'does not search for job users' do + expect(::Ci::Build).not_to receive(:find_by_token) + + expect(subject.find_sessionless_user([:api])).to be_nil + end + end + end + describe '#runner' do let!(:runner) { build(:ci_runner) } diff --git a/spec/lib/gitlab/auth_spec.rb b/spec/lib/gitlab/auth_spec.rb index 311cbd4dd7e22de76c48417a811577649d2e6ced..1f943bebbec932ff740af1462f14eb6402630770 100644 --- a/spec/lib/gitlab/auth_spec.rb +++ b/spec/lib/gitlab/auth_spec.rb @@ -130,6 +130,15 @@ describe Gitlab::Auth, :use_clean_rails_memory_store_caching do gl_auth.find_for_git_client(user.username, user.password, project: nil, ip: 'ip') end + it 'rate limits a user by unique IPs' do + expect_next_instance_of(Gitlab::Auth::IpRateLimiter) do |rate_limiter| + expect(rate_limiter).to receive(:reset!) + end + expect(Gitlab::Auth::UniqueIpsLimiter).to receive(:limit_user!).twice.and_call_original + + gl_auth.find_for_git_client(user.username, user.password, project: nil, ip: 'ip') + end + it 'registers failure for failed auth' do expect_next_instance_of(Gitlab::Auth::IpRateLimiter) do |rate_limiter| expect(rate_limiter).to receive(:register_fail!) @@ -415,6 +424,12 @@ describe Gitlab::Auth, :use_clean_rails_memory_store_caching do .to eq(auth_success) end + it 'does not attempt to rate limit unique IPs for a deploy token' do + expect(Gitlab::Auth::UniqueIpsLimiter).not_to receive(:limit_user!) + + gl_auth.find_for_git_client(login, deploy_token.token, project: project, ip: 'ip') + end + it 'fails when login is not valid' do expect(gl_auth.find_for_git_client('random_login', deploy_token.token, project: project, ip: 'ip')) .to eq(auth_failure) diff --git a/spec/lib/gitlab/background_migration/activate_prometheus_services_for_shared_cluster_applications_spec.rb b/spec/lib/gitlab/background_migration/activate_prometheus_services_for_shared_cluster_applications_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..0edf87e13542d763ae52a96d17c4c61260e93770 --- /dev/null +++ b/spec/lib/gitlab/background_migration/activate_prometheus_services_for_shared_cluster_applications_spec.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::BackgroundMigration::ActivatePrometheusServicesForSharedClusterApplications, :migration, schema: 2020_01_14_113341 do + include MigrationHelpers::PrometheusServiceHelpers + + let(:namespaces) { table(:namespaces) } + let(:projects) { table(:projects) } + let(:services) { table(:services) } + let(:namespace) { namespaces.create(name: 'user', path: 'user') } + let(:project) { projects.create(namespace_id: namespace.id) } + + let(:columns) do + %w(project_id active properties type template push_events + issues_events merge_requests_events tag_push_events + note_events category default wiki_page_events pipeline_events + confidential_issues_events commit_events job_events + confidential_note_events deployment_events) + end + + describe '#perform' do + it 'is idempotent' do + expect { subject.perform(project.id) }.to change { services.order(:id).map { |row| row.attributes } } + + expect { subject.perform(project.id) }.not_to change { services.order(:id).map { |row| row.attributes } } + end + + context 'non prometheus services' do + it 'does not change them' do + other_type = 'SomeOtherService' + services.create(service_params_for(project.id, active: true, type: other_type)) + + expect { subject.perform(project.id) }.not_to change { services.where(type: other_type).order(:id).map { |row| row.attributes } } + end + end + + context 'prometheus services are configured manually ' do + it 'does not change them' do + properties = '{"api_url":"http://test.dev","manual_configuration":"1"}' + services.create(service_params_for(project.id, properties: properties, active: false)) + + expect { subject.perform(project.id) }.not_to change { services.order(:id).map { |row| row.attributes } } + end + end + + context 'prometheus integration services do not exist' do + it 'creates missing services entries' do + subject.perform(project.id) + + rows = services.order(:id).map { |row| row.attributes.slice(*columns).symbolize_keys } + + expect([service_params_for(project.id, active: true)]).to eq rows + end + end + + context 'prometheus integration services exist' do + context 'in active state' do + it 'does not change them' do + services.create(service_params_for(project.id, active: true)) + + expect { subject.perform(project.id) }.not_to change { services.order(:id).map { |row| row.attributes } } + end + end + + context 'not in active state' do + it 'sets active attribute to true' do + service = services.create(service_params_for(project.id)) + + expect { subject.perform(project.id) }.to change { service.reload.active? }.from(false).to(true) + end + end + end + end +end diff --git a/spec/lib/gitlab/background_migration/migrate_fingerprint_sha256_within_keys_spec.rb b/spec/lib/gitlab/background_migration/migrate_fingerprint_sha256_within_keys_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..3ccb2379936e020de4b6faf516d14ea4af3112f9 --- /dev/null +++ b/spec/lib/gitlab/background_migration/migrate_fingerprint_sha256_within_keys_spec.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::BackgroundMigration::MigrateFingerprintSha256WithinKeys, :migration, schema: 20200106071113 do + subject(:fingerprint_migrator) { described_class.new } + + let(:key_table) { table(:keys) } + + before do + generate_fingerprints! + end + + it 'correctly creates a sha256 fingerprint for a key' do + key_1 = Key.find(1017) + key_2 = Key.find(1027) + + expect(key_1.fingerprint_md5).to eq('ba:81:59:68:d7:6c:cd:02:02:bf:6a:9b:55:4e:af:d1') + expect(key_1.fingerprint_sha256).to eq(nil) + + expect(key_2.fingerprint_md5).to eq('39:e3:64:a6:24:ea:45:a2:8c:55:2a:e9:4d:4f:1f:b4') + expect(key_2.fingerprint_sha256).to eq(nil) + + query_count = ActiveRecord::QueryRecorder.new do + fingerprint_migrator.perform(1, 10000) + end.count + + expect(query_count).to eq(8) + + key_1.reload + key_2.reload + + expect(key_1.fingerprint_md5).to eq('ba:81:59:68:d7:6c:cd:02:02:bf:6a:9b:55:4e:af:d1') + expect(key_1.fingerprint_sha256).to eq('nUhzNyftwADy8AH3wFY31tAKs7HufskYTte2aXo/lCg') + + expect(key_2.fingerprint_md5).to eq('39:e3:64:a6:24:ea:45:a2:8c:55:2a:e9:4d:4f:1f:b4') + expect(key_2.fingerprint_sha256).to eq('zMNbLekgdjtcgDv8VSC0z5lpdACMG3Q4PUoIz5+H2jM') + end + + context 'with invalid keys' do + before do + key = Key.find(1017) + # double space after "ssh-rsa" leads to a + # OpenSSL::PKey::PKeyError in Net::SSH::KeyFactory.load_data_public_key + key.update_column(:key, key.key.gsub('ssh-rsa ', 'ssh-rsa ')) + end + + it 'ignores errors and does not set the fingerprint' do + fingerprint_migrator.perform(1, 10000) + + key_1 = Key.find(1017) + key_2 = Key.find(1027) + + expect(key_1.fingerprint_sha256).to be_nil + expect(key_2.fingerprint_sha256).not_to be_nil + end + end + + it 'migrates all keys' do + expect(Key.where(fingerprint_sha256: nil).count).to eq(Key.all.count) + + fingerprint_migrator.perform(1, 10000) + + expect(Key.where(fingerprint_sha256: nil).count).to eq(0) + end + + def generate_fingerprints! + values = "" + (1000..2000).to_a.each do |record| + key = base_key_for(record) + fingerprint = fingerprint_for(key) + + values += "(#{record}, #{record}, 'test-#{record}', '#{key}', '#{fingerprint}')," + end + + update_query = <<~SQL + INSERT INTO keys ( id, user_id, title, key, fingerprint ) + VALUES + #{values.chomp(",")}; + SQL + + ActiveRecord::Base.connection.execute(update_query) + end + + def base_key_for(record) + 'ssh-rsa AAAAB3NzaC1yc2EAAAABJQAAAIEAiPWx6WM4lhHNedGfBpPJNPpZ7yKu+dnn1SJejgt0000k6YjzGGphH2TUxwKzxcKDKKezwkpfnxPkSMkuEspGRt/aZZ9wa++Oi7Qkr8prgHc4soW6NUlfDzpvZK2H5E7eQaSeP3SAwGmQKUFHCddNaP0L+hM7zhFNzjFvpaMgJw0=' + .gsub("0000", "%04d" % (record - 1)) # generate arbitrary keys with placeholder 0000 within the key above + end + + def fingerprint_for(key) + Gitlab::SSHPublicKey.new(key).fingerprint("md5") + end +end diff --git a/spec/lib/gitlab/background_migration/migrate_issue_trackers_sensitive_data_spec.rb b/spec/lib/gitlab/background_migration/migrate_issue_trackers_sensitive_data_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..664e3810fc9efc19f6f8b3a3d933dad9599f4c3c --- /dev/null +++ b/spec/lib/gitlab/background_migration/migrate_issue_trackers_sensitive_data_spec.rb @@ -0,0 +1,320 @@ +# frozen_string_literal: true +require 'spec_helper' + +describe Gitlab::BackgroundMigration::MigrateIssueTrackersSensitiveData, :migration, schema: 20190924152703 do + let(:services) { table(:services) } + + # we need to define the classes due to encryption + class IssueTrackerData < ApplicationRecord + self.table_name = 'issue_tracker_data' + + def self.encryption_options + { + key: Settings.attr_encrypted_db_key_base_32, + encode: true, + mode: :per_attribute_iv, + algorithm: 'aes-256-gcm' + } + end + + attr_encrypted :project_url, encryption_options + attr_encrypted :issues_url, encryption_options + attr_encrypted :new_issue_url, encryption_options + end + + class JiraTrackerData < ApplicationRecord + self.table_name = 'jira_tracker_data' + + def self.encryption_options + { + key: Settings.attr_encrypted_db_key_base_32, + encode: true, + mode: :per_attribute_iv, + algorithm: 'aes-256-gcm' + } + end + + attr_encrypted :url, encryption_options + attr_encrypted :api_url, encryption_options + attr_encrypted :username, encryption_options + attr_encrypted :password, encryption_options + end + + let(:url) { 'http://base-url.tracker.com' } + let(:new_issue_url) { 'http://base-url.tracker.com/new_issue' } + let(:issues_url) { 'http://base-url.tracker.com/issues' } + let(:api_url) { 'http://api.tracker.com' } + let(:password) { 'passw1234' } + let(:username) { 'user9' } + let(:title) { 'Issue tracker' } + let(:description) { 'Issue tracker description' } + + let(:jira_properties) do + { + 'api_url' => api_url, + 'jira_issue_transition_id' => '5', + 'password' => password, + 'url' => url, + 'username' => username, + 'title' => title, + 'description' => description, + 'other_field' => 'something' + } + end + + let(:tracker_properties) do + { + 'project_url' => url, + 'new_issue_url' => new_issue_url, + 'issues_url' => issues_url, + 'title' => title, + 'description' => description, + 'other_field' => 'something' + } + end + + let(:tracker_properties_no_url) do + { + 'new_issue_url' => new_issue_url, + 'issues_url' => issues_url, + 'title' => title, + 'description' => description + } + end + + subject { described_class.new.perform(1, 100) } + + shared_examples 'handle properties' do + it 'does not clear the properties' do + expect { subject }.not_to change { service.reload.properties} + end + end + + context 'with jira service' do + let!(:service) do + services.create(id: 10, type: 'JiraService', title: nil, properties: jira_properties.to_json, category: 'issue_tracker') + end + + it_behaves_like 'handle properties' + + it 'migrates data' do + expect { subject }.to change { JiraTrackerData.count }.by(1) + + service.reload + data = JiraTrackerData.find_by(service_id: service.id) + + expect(data.url).to eq(url) + expect(data.api_url).to eq(api_url) + expect(data.username).to eq(username) + expect(data.password).to eq(password) + expect(service.title).to eq(title) + expect(service.description).to eq(description) + end + end + + context 'with bugzilla service' do + let!(:service) do + services.create(id: 11, type: 'BugzillaService', title: nil, properties: tracker_properties.to_json, category: 'issue_tracker') + end + + it_behaves_like 'handle properties' + + it 'migrates data' do + expect { subject }.to change { IssueTrackerData.count }.by(1) + + service.reload + data = IssueTrackerData.find_by(service_id: service.id) + + expect(data.project_url).to eq(url) + expect(data.issues_url).to eq(issues_url) + expect(data.new_issue_url).to eq(new_issue_url) + expect(service.title).to eq(title) + expect(service.description).to eq(description) + end + end + + context 'with youtrack service' do + let!(:service) do + services.create(id: 12, type: 'YoutrackService', title: nil, properties: tracker_properties_no_url.to_json, category: 'issue_tracker') + end + + it_behaves_like 'handle properties' + + it 'migrates data' do + expect { subject }.to change { IssueTrackerData.count }.by(1) + + service.reload + data = IssueTrackerData.find_by(service_id: service.id) + + expect(data.project_url).to be_nil + expect(data.issues_url).to eq(issues_url) + expect(data.new_issue_url).to eq(new_issue_url) + expect(service.title).to eq(title) + expect(service.description).to eq(description) + end + end + + context 'with gitlab service with no properties' do + let!(:service) do + services.create(id: 13, type: 'GitlabIssueTrackerService', title: nil, properties: {}, category: 'issue_tracker') + end + + it_behaves_like 'handle properties' + + it 'does not migrate data' do + expect { subject }.not_to change { IssueTrackerData.count } + end + end + + context 'with redmine service already with data fields' do + let!(:service) do + services.create(id: 14, type: 'RedmineService', title: nil, properties: tracker_properties_no_url.to_json, category: 'issue_tracker').tap do |service| + IssueTrackerData.create!(service_id: service.id, project_url: url, new_issue_url: new_issue_url, issues_url: issues_url) + end + end + + it_behaves_like 'handle properties' + + it 'does not create new data fields record' do + expect { subject }.not_to change { IssueTrackerData.count } + end + end + + context 'with custom issue tracker which has data fields record inconsistent with properties field' do + let!(:service) do + services.create(id: 15, type: 'CustomIssueTrackerService', title: 'Existing title', properties: jira_properties.to_json, category: 'issue_tracker').tap do |service| + IssueTrackerData.create!(service_id: service.id, project_url: 'http://other_url', new_issue_url: 'http://other_url/new_issue', issues_url: 'http://other_url/issues') + end + end + + it_behaves_like 'handle properties' + + it 'does not update the data fields record' do + expect { subject }.not_to change { IssueTrackerData.count } + + service.reload + data = IssueTrackerData.find_by(service_id: service.id) + + expect(data.project_url).to eq('http://other_url') + expect(data.issues_url).to eq('http://other_url/issues') + expect(data.new_issue_url).to eq('http://other_url/new_issue') + expect(service.title).to eq('Existing title') + end + end + + context 'with jira service which has data fields record inconsistent with properties field' do + let!(:service) do + services.create(id: 16, type: 'CustomIssueTrackerService', description: 'Existing description', properties: jira_properties.to_json, category: 'issue_tracker').tap do |service| + JiraTrackerData.create!(service_id: service.id, url: 'http://other_jira_url') + end + end + + it_behaves_like 'handle properties' + + it 'does not update the data fields record' do + expect { subject }.not_to change { JiraTrackerData.count } + + service.reload + data = JiraTrackerData.find_by(service_id: service.id) + + expect(data.url).to eq('http://other_jira_url') + expect(data.password).to be_nil + expect(data.username).to be_nil + expect(data.api_url).to be_nil + expect(service.description).to eq('Existing description') + end + end + + context 'non issue tracker service' do + let!(:service) do + services.create(id: 17, title: nil, description: nil, type: 'OtherService', properties: tracker_properties.to_json) + end + + it_behaves_like 'handle properties' + + it 'does not migrate any data' do + expect { subject }.not_to change { IssueTrackerData.count } + + service.reload + expect(service.title).to be_nil + expect(service.description).to be_nil + end + end + + context 'jira service with empty properties' do + let!(:service) do + services.create(id: 18, type: 'JiraService', properties: '', category: 'issue_tracker') + end + + it_behaves_like 'handle properties' + + it 'does not migrate any data' do + expect { subject }.not_to change { JiraTrackerData.count } + end + end + + context 'jira service with nil properties' do + let!(:service) do + services.create(id: 18, type: 'JiraService', properties: nil, category: 'issue_tracker') + end + + it_behaves_like 'handle properties' + + it 'does not migrate any data' do + expect { subject }.not_to change { JiraTrackerData.count } + end + end + + context 'jira service with invalid properties' do + let!(:service) do + services.create(id: 18, type: 'JiraService', properties: 'invalid data', category: 'issue_tracker') + end + + it_behaves_like 'handle properties' + + it 'does not migrate any data' do + expect { subject }.not_to change { JiraTrackerData.count } + end + end + + context 'with jira service with invalid properties, valid jira service and valid bugzilla service' do + let!(:jira_service_invalid) do + services.create(id: 19, title: 'invalid - title', description: 'invalid - description', type: 'JiraService', properties: 'invalid data', category: 'issue_tracker') + end + let!(:jira_service_valid) do + services.create(id: 20, type: 'JiraService', properties: jira_properties.to_json, category: 'issue_tracker') + end + let!(:bugzilla_service_valid) do + services.create(id: 11, type: 'BugzillaService', title: nil, properties: tracker_properties.to_json, category: 'issue_tracker') + end + + it 'migrates data for the valid service' do + subject + + jira_service_invalid.reload + expect(JiraTrackerData.find_by(service_id: jira_service_invalid.id)).to be_nil + expect(jira_service_invalid.title).to eq('invalid - title') + expect(jira_service_invalid.description).to eq('invalid - description') + expect(jira_service_invalid.properties).to eq('invalid data') + + jira_service_valid.reload + data = JiraTrackerData.find_by(service_id: jira_service_valid.id) + + expect(data.url).to eq(url) + expect(data.api_url).to eq(api_url) + expect(data.username).to eq(username) + expect(data.password).to eq(password) + expect(jira_service_valid.title).to eq(title) + expect(jira_service_valid.description).to eq(description) + + bugzilla_service_valid.reload + data = IssueTrackerData.find_by(service_id: bugzilla_service_valid.id) + + expect(data.project_url).to eq(url) + expect(data.issues_url).to eq(issues_url) + expect(data.new_issue_url).to eq(new_issue_url) + expect(bugzilla_service_valid.title).to eq(title) + expect(bugzilla_service_valid.description).to eq(description) + end + end +end diff --git a/spec/lib/gitlab/background_migration_spec.rb b/spec/lib/gitlab/background_migration_spec.rb index 8960ac706e69d958d38ff62a8dec36c984d135f4..66a0b11606fcf68491c609205bfdf18cea11ced7 100644 --- a/spec/lib/gitlab/background_migration_spec.rb +++ b/spec/lib/gitlab/background_migration_spec.rb @@ -152,6 +152,17 @@ describe Gitlab::BackgroundMigration do described_class.perform('Foo', [10, 20]) end + + context 'backward compatibility' do + it 'performs a background migration for fully-qualified job classes' do + expect(migration).to receive(:perform).with(10, 20).once + expect(Gitlab::ErrorTracking) + .to receive(:track_and_raise_for_dev_exception) + .with(instance_of(StandardError), hash_including(:class_name)) + + described_class.perform('Gitlab::BackgroundMigration::Foo', [10, 20]) + end + end end describe '.exists?' do diff --git a/spec/lib/gitlab/backtrace_cleaner_spec.rb b/spec/lib/gitlab/backtrace_cleaner_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..f3aded9faad9401c4d6619d7deb4131f99e719be --- /dev/null +++ b/spec/lib/gitlab/backtrace_cleaner_spec.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::BacktraceCleaner do + describe '.clean_backtrace' do + it 'uses the Rails backtrace cleaner' do + backtrace = [] + + expect(Rails.backtrace_cleaner).to receive(:clean).with(backtrace) + + described_class.clean_backtrace(backtrace) + end + + it 'removes lines from IGNORE_BACKTRACES' do + backtrace = [ + "lib/gitlab/gitaly_client.rb:294:in `block (2 levels) in migrate'", + "lib/gitlab/gitaly_client.rb:331:in `allow_n_plus_1_calls'", + "lib/gitlab/gitaly_client.rb:280:in `block in migrate'", + "lib/gitlab/metrics/influx_db.rb:103:in `measure'", + "lib/gitlab/gitaly_client.rb:278:in `migrate'", + "lib/gitlab/git/repository.rb:1451:in `gitaly_migrate'", + "lib/gitlab/git/commit.rb:66:in `find'", + "app/models/repository.rb:1047:in `find_commit'", + "lib/gitlab/metrics/instrumentation.rb:159:in `block in find_commit'", + "lib/gitlab/metrics/method_call.rb:36:in `measure'", + "lib/gitlab/metrics/instrumentation.rb:159:in `find_commit'", + "app/models/repository.rb:113:in `commit'", + "lib/gitlab/i18n.rb:50:in `with_locale'", + "lib/gitlab/middleware/multipart.rb:95:in `call'", + "lib/gitlab/request_profiler/middleware.rb:14:in `call'", + "ee/lib/gitlab/database/load_balancing/rack_middleware.rb:37:in `call'", + "ee/lib/gitlab/jira/middleware.rb:15:in `call'" + ] + + expect(described_class.clean_backtrace(backtrace)) + .to eq([ + "lib/gitlab/gitaly_client.rb:294:in `block (2 levels) in migrate'", + "lib/gitlab/gitaly_client.rb:331:in `allow_n_plus_1_calls'", + "lib/gitlab/gitaly_client.rb:280:in `block in migrate'", + "lib/gitlab/gitaly_client.rb:278:in `migrate'", + "lib/gitlab/git/repository.rb:1451:in `gitaly_migrate'", + "lib/gitlab/git/commit.rb:66:in `find'", + "app/models/repository.rb:1047:in `find_commit'", + "app/models/repository.rb:113:in `commit'", + "ee/lib/gitlab/jira/middleware.rb:15:in `call'" + ]) + end + end +end diff --git a/spec/lib/gitlab/badge/coverage/report_spec.rb b/spec/lib/gitlab/badge/coverage/report_spec.rb index eee3f96ab8539d4436042171936f71cba2d86822..560072a3d83bd92b1ace8e8d46f35e669472a7d5 100644 --- a/spec/lib/gitlab/badge/coverage/report_spec.rb +++ b/spec/lib/gitlab/badge/coverage/report_spec.rb @@ -102,7 +102,7 @@ describe Gitlab::Badge::Coverage::Report do create(:ci_pipeline, opts).tap do |pipeline| yield pipeline - pipeline.update_status + pipeline.update_legacy_status end end end diff --git a/spec/lib/gitlab/ci/build/policy/refs_spec.rb b/spec/lib/gitlab/ci/build/policy/refs_spec.rb index 8fc1e0a4e880a8085e146ada3e6472743c64fb1d..c32fdc5c72ec8a506dc07a02c343d7416012372a 100644 --- a/spec/lib/gitlab/ci/build/policy/refs_spec.rb +++ b/spec/lib/gitlab/ci/build/policy/refs_spec.rb @@ -98,6 +98,34 @@ describe Gitlab::Ci::Build::Policy::Refs do .not_to be_satisfied_by(pipeline) end end + + context 'when source is pipeline' do + let(:pipeline) { build_stubbed(:ci_pipeline, source: :pipeline) } + + it 'is satisfied with only: pipelines' do + expect(described_class.new(%w[pipelines])) + .to be_satisfied_by(pipeline) + end + + it 'is satisfied with only: pipeline' do + expect(described_class.new(%w[pipeline])) + .to be_satisfied_by(pipeline) + end + end + + context 'when source is parent_pipeline' do + let(:pipeline) { build_stubbed(:ci_pipeline, source: :parent_pipeline) } + + it 'is satisfied with only: parent_pipelines' do + expect(described_class.new(%w[parent_pipelines])) + .to be_satisfied_by(pipeline) + end + + it 'is satisfied with only: parent_pipeline' do + expect(described_class.new(%w[parent_pipeline])) + .to be_satisfied_by(pipeline) + end + end end context 'when matching a ref by a regular expression' do diff --git a/spec/lib/gitlab/ci/config/entry/cache_spec.rb b/spec/lib/gitlab/ci/config/entry/cache_spec.rb index 4fa0a57dc82e0b2cfc6e127a358eccb78d28cce9..f7b14360af30b2b6421c69286bd87509eeb81479 100644 --- a/spec/lib/gitlab/ci/config/entry/cache_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/cache_spec.rb @@ -31,13 +31,13 @@ describe Gitlab::Ci::Config::Entry::Cache do it_behaves_like 'hash key value' context 'with files' do - let(:key) { { files: ['a-file', 'other-file'] } } + let(:key) { { files: %w[a-file other-file] } } it_behaves_like 'hash key value' end context 'with files and prefix' do - let(:key) { { files: ['a-file', 'other-file'], prefix: 'prefix-value' } } + let(:key) { { files: %w[a-file other-file], prefix: 'prefix-value' } } it_behaves_like 'hash key value' end @@ -55,7 +55,7 @@ describe Gitlab::Ci::Config::Entry::Cache do it { is_expected.to be_valid } context 'with files' do - let(:key) { { files: ['a-file', 'other-file'] } } + let(:key) { { files: %w[a-file other-file] } } it { is_expected.to be_valid } end diff --git a/spec/lib/gitlab/ci/config/entry/job_spec.rb b/spec/lib/gitlab/ci/config/entry/job_spec.rb index cc1ee63ff044127e8186c6caf07756ef694f76e0..649689f7d3be690fa335136974402ed41a7c39f8 100644 --- a/spec/lib/gitlab/ci/config/entry/job_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/job_spec.rb @@ -24,7 +24,7 @@ describe Gitlab::Ci::Config::Entry::Job do let(:result) do %i[before_script script stage type after_script cache image services only except rules needs variables artifacts - environment coverage retry interruptible timeout tags] + environment coverage retry interruptible timeout release tags] end it { is_expected.to match_array result } @@ -122,6 +122,21 @@ describe Gitlab::Ci::Config::Entry::Job do it { expect(entry).to be_valid } end + + context 'when it is a release' do + let(:config) do + { + script: ["make changelog | tee release_changelog.txt"], + release: { + tag_name: "v0.06", + name: "Release $CI_TAG_NAME", + description: "./release_changelog.txt" + } + } + end + + it { expect(entry).to be_valid } + end end end @@ -443,6 +458,25 @@ describe Gitlab::Ci::Config::Entry::Job do expect(entry.timeout).to eq('1m 1s') end end + + context 'when it is a release' do + context 'when `release:description` is missing' do + let(:config) do + { + script: ["make changelog | tee release_changelog.txt"], + release: { + tag_name: "v0.06", + name: "Release $CI_TAG_NAME" + } + } + end + + it "returns error" do + expect(entry).not_to be_valid + expect(entry.errors).to include "release description can't be blank" + end + end + end end end diff --git a/spec/lib/gitlab/ci/config/entry/release/assets/link_spec.rb b/spec/lib/gitlab/ci/config/entry/release/assets/link_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..0e346de3d9e9601f5e4461686ee7c8cc2cd66ebf --- /dev/null +++ b/spec/lib/gitlab/ci/config/entry/release/assets/link_spec.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::Ci::Config::Entry::Release::Assets::Link do + let(:entry) { described_class.new(config) } + + describe 'validation' do + context 'when entry config value is correct' do + let(:config) do + { + name: "cool-app.zip", + url: "http://my.awesome.download.site/1.0-$CI_COMMIT_SHORT_SHA.zip" + } + end + + describe '#value' do + it 'returns link configuration' do + expect(entry.value).to eq config + end + end + + describe '#valid?' do + it 'is valid' do + expect(entry).to be_valid + end + end + end + + context 'when entry value is not correct' do + describe '#errors' do + context 'when name is not a string' do + let(:config) { { name: 123, url: "http://my.awesome.download.site/1.0-$CI_COMMIT_SHORT_SHA.zip" } } + + it 'reports error' do + expect(entry.errors) + .to include 'link name should be a string' + end + end + + context 'when name is not present' do + let(:config) { { url: "http://my.awesome.download.site/1.0-$CI_COMMIT_SHORT_SHA.zip" } } + + it 'reports error' do + expect(entry.errors) + .to include "link name can't be blank" + end + end + + context 'when url is not addressable' do + let(:config) { { name: "cool-app.zip", url: "xyz" } } + + it 'reports error' do + expect(entry.errors) + .to include "link url is blocked: only allowed schemes are http, https" + end + end + + context 'when url is not present' do + let(:config) { { name: "cool-app.zip" } } + + it 'reports error' do + expect(entry.errors) + .to include "link url can't be blank" + end + end + + context 'when there is an unknown key present' do + let(:config) { { test: 100 } } + + it 'reports error' do + expect(entry.errors) + .to include 'link config contains unknown keys: test' + end + end + end + end + end +end diff --git a/spec/lib/gitlab/ci/config/entry/release/assets/links_spec.rb b/spec/lib/gitlab/ci/config/entry/release/assets/links_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..d12e8d966ab4a49fefd17d06acff5a7ab0ee270f --- /dev/null +++ b/spec/lib/gitlab/ci/config/entry/release/assets/links_spec.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::Ci::Config::Entry::Release::Assets::Links do + let(:entry) { described_class.new(config) } + + describe 'validation' do + context 'when entry config value is correct' do + let(:config) do + [ + { + name: "cool-app.zip", + url: "http://my.awesome.download.site/1.0-$CI_COMMIT_SHORT_SHA.zip" + }, + { + name: "cool-app.exe", + url: "http://my.awesome.download.site/1.0-$CI_COMMIT_SHORT_SHA.exe" + } + ] + end + + describe '#value' do + it 'returns links configuration' do + expect(entry.value).to eq config + end + end + + describe '#valid?' do + it 'is valid' do + expect(entry).to be_valid + end + end + end + + context 'when entry value is not correct' do + describe '#errors' do + context 'when value of link is invalid' do + let(:config) { { link: 'xyz' } } + + it 'reports error' do + expect(entry.errors) + .to include 'links config should be a array' + end + end + + context 'when value of links link is empty' do + let(:config) { { link: [] } } + + it 'reports error' do + expect(entry.errors) + .to include "links config should be a array" + end + end + + context 'when there is an unknown key present' do + let(:config) { { test: 100 } } + + it 'reports error' do + expect(entry.errors) + .to include 'links config should be a array' + end + end + end + end + end +end diff --git a/spec/lib/gitlab/ci/config/entry/release/assets_spec.rb b/spec/lib/gitlab/ci/config/entry/release/assets_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..08ad5764eaacedcb841c3e43347932e4ad741b95 --- /dev/null +++ b/spec/lib/gitlab/ci/config/entry/release/assets_spec.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::Ci::Config::Entry::Release::Assets do + let(:entry) { described_class.new(config) } + + describe 'validation' do + context 'when entry config value is correct' do + let(:config) do + { + links: [ + { + name: "cool-app.zip", + url: "http://my.awesome.download.site/1.0-$CI_COMMIT_SHORT_SHA.zip" + }, + { + name: "cool-app.exe", + url: "http://my.awesome.download.site/1.0-$CI_COMMIT_SHORT_SHA.exe" + } + ] + } + end + + describe '#value' do + it 'returns assets configuration' do + expect(entry.value).to eq config + end + end + + describe '#valid?' do + it 'is valid' do + expect(entry).to be_valid + end + end + end + + context 'when entry value is not correct' do + describe '#errors' do + context 'when value of assets is invalid' do + let(:config) { { links: 'xyz' } } + + it 'reports error' do + expect(entry.errors) + .to include 'assets links should be an array of hashes' + end + end + + context 'when value of assets:links is empty' do + let(:config) { { links: [] } } + + it 'reports error' do + expect(entry.errors) + .to include "assets links can't be blank" + end + end + + context 'when there is an unknown key present' do + let(:config) { { test: 100 } } + + it 'reports error' do + expect(entry.errors) + .to include 'assets config contains unknown keys: test' + end + end + end + end + end +end diff --git a/spec/lib/gitlab/ci/config/entry/release_spec.rb b/spec/lib/gitlab/ci/config/entry/release_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..500897569e915bef9ad3b15648174bafc8d423fb --- /dev/null +++ b/spec/lib/gitlab/ci/config/entry/release_spec.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::Ci::Config::Entry::Release do + let(:entry) { described_class.new(config) } + + describe 'validation' do + context 'when entry config value is correct' do + let(:config) { { tag_name: 'v0.06', description: "./release_changelog.txt" } } + + describe '#value' do + it 'returns release configuration' do + expect(entry.value).to eq config + end + end + + describe '#valid?' do + it 'is valid' do + expect(entry).to be_valid + end + end + end + + context "when value includes 'assets' keyword" do + let(:config) do + { + tag_name: 'v0.06', + description: "./release_changelog.txt", + assets: [ + { + name: "cool-app.zip", + url: "http://my.awesome.download.site/1.0-$CI_COMMIT_SHORT_SHA.zip" + } + ] + } + end + + describe '#value' do + it 'returns release configuration' do + expect(entry.value).to eq config + end + end + + describe '#valid?' do + it 'is valid' do + expect(entry).to be_valid + end + end + end + + context "when value includes 'name' keyword" do + let(:config) do + { + tag_name: 'v0.06', + description: "./release_changelog.txt", + name: "Release $CI_TAG_NAME" + } + end + + describe '#value' do + it 'returns release configuration' do + expect(entry.value).to eq config + end + end + + describe '#valid?' do + it 'is valid' do + expect(entry).to be_valid + end + end + end + + context 'when entry value is not correct' do + describe '#errors' do + context 'when value of attribute is invalid' do + let(:config) { { description: 10 } } + + it 'reports error' do + expect(entry.errors) + .to include 'release description should be a string' + end + end + + context 'when release description is missing' do + let(:config) { { tag_name: 'v0.06' } } + + it 'reports error' do + expect(entry.errors) + .to include "release description can't be blank" + end + end + + context 'when release tag_name is missing' do + let(:config) { { description: "./release_changelog.txt" } } + + it 'reports error' do + expect(entry.errors) + .to include "release tag name can't be blank" + end + end + + context 'when there is an unknown key present' do + let(:config) { { test: 100 } } + + it 'reports error' do + expect(entry.errors) + .to include 'release config contains unknown keys: test' + end + end + end + end + end +end diff --git a/spec/lib/gitlab/ci/config/entry/reports_spec.rb b/spec/lib/gitlab/ci/config/entry/reports_spec.rb index 3c352c30e55c377c342377573d6f3a15ed76f35b..8562885c90c639280407c0e0a13ad51705266bd4 100644 --- a/spec/lib/gitlab/ci/config/entry/reports_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/reports_spec.rb @@ -41,6 +41,7 @@ describe Gitlab::Ci::Config::Entry::Reports do :container_scanning | 'gl-container-scanning-report.json' :dast | 'gl-dast-report.json' :license_management | 'gl-license-management-report.json' + :license_scanning | 'gl-license-scanning-report.json' :performance | 'performance.json' end diff --git a/spec/lib/gitlab/ci/config/entry/root_spec.rb b/spec/lib/gitlab/ci/config/entry/root_spec.rb index 43bd53b780f9c3395a1b6c0df2f125fa3eb0a71a..95a5b8e88fbd5921c719e51371ed283e9aa2b4a0 100644 --- a/spec/lib/gitlab/ci/config/entry/root_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/root_spec.rb @@ -27,16 +27,29 @@ describe Gitlab::Ci::Config::Entry::Root do context 'when configuration is valid' do context 'when top-level entries are defined' do let(:hash) do - { before_script: %w(ls pwd), + { + before_script: %w(ls pwd), image: 'ruby:2.2', default: {}, services: ['postgres:9.1', 'mysql:5.5'], variables: { VAR: 'value' }, after_script: ['make clean'], - stages: %w(build pages), + stages: %w(build pages release), cache: { key: 'k', untracked: true, paths: ['public/'] }, rspec: { script: %w[rspec ls] }, - spinach: { before_script: [], variables: {}, script: 'spinach' } } + spinach: { before_script: [], variables: {}, script: 'spinach' }, + release: { + stage: 'release', + before_script: [], + after_script: [], + script: ["make changelog | tee release_changelog.txt"], + release: { + tag_name: 'v0.06', + name: "Release $CI_TAG_NAME", + description: "./release_changelog.txt" + } + } + } end describe '#compose!' do @@ -87,7 +100,7 @@ describe Gitlab::Ci::Config::Entry::Root do describe '#stages_value' do context 'when stages key defined' do it 'returns array of stages' do - expect(root.stages_value).to eq %w[build pages] + expect(root.stages_value).to eq %w[build pages release] end end @@ -105,8 +118,9 @@ describe Gitlab::Ci::Config::Entry::Root do describe '#jobs_value' do it 'returns jobs configuration' do - expect(root.jobs_value).to eq( - rspec: { name: :rspec, + expect(root.jobs_value.keys).to eq([:rspec, :spinach, :release]) + expect(root.jobs_value[:rspec]).to eq( + { name: :rspec, script: %w[rspec ls], before_script: %w(ls pwd), image: { name: 'ruby:2.2' }, @@ -116,8 +130,10 @@ describe Gitlab::Ci::Config::Entry::Root do variables: {}, ignore: false, after_script: ['make clean'], - only: { refs: %w[branches tags] } }, - spinach: { name: :spinach, + only: { refs: %w[branches tags] } } + ) + expect(root.jobs_value[:spinach]).to eq( + { name: :spinach, before_script: [], script: %w[spinach], image: { name: 'ruby:2.2' }, @@ -129,6 +145,20 @@ describe Gitlab::Ci::Config::Entry::Root do after_script: ['make clean'], only: { refs: %w[branches tags] } } ) + expect(root.jobs_value[:release]).to eq( + { name: :release, + stage: 'release', + before_script: [], + script: ["make changelog | tee release_changelog.txt"], + release: { name: "Release $CI_TAG_NAME", tag_name: 'v0.06', description: "./release_changelog.txt" }, + image: { name: "ruby:2.2" }, + services: [{ name: "postgres:9.1" }, { name: "mysql:5.5" }], + cache: { key: "k", untracked: true, paths: ["public/"], policy: "pull-push" }, + only: { refs: %w(branches tags) }, + variables: {}, + after_script: [], + ignore: false } + ) end end end @@ -261,7 +291,7 @@ describe Gitlab::Ci::Config::Entry::Root do # despite the fact, that key is present. See issue #18775 for more # details. # - context 'when entires specified but not defined' do + context 'when entries are specified but not defined' do before do root.compose! end diff --git a/spec/lib/gitlab/ci/pipeline/chain/config/content_spec.rb b/spec/lib/gitlab/ci/pipeline/chain/config/content_spec.rb index 7ebe5842fd05e630afcc9ddc66505fe447b0e3fc..4c4359ad5d2ad2264487d303bd3931175c914dc6 100644 --- a/spec/lib/gitlab/ci/pipeline/chain/config/content_spec.rb +++ b/spec/lib/gitlab/ci/pipeline/chain/config/content_spec.rb @@ -15,6 +15,42 @@ describe Gitlab::Ci::Pipeline::Chain::Config::Content do stub_feature_flags(ci_root_config_content: false) end + context 'when bridge job is passed in as parameter' do + let(:ci_config_path) { nil } + let(:bridge) { create(:ci_bridge) } + + before do + command.bridge = bridge + end + + context 'when bridge job has downstream yaml' do + before do + allow(bridge).to receive(:yaml_for_downstream).and_return('the-yaml') + end + + it 'returns the content already available in command' do + subject.perform! + + expect(pipeline.config_source).to eq 'bridge_source' + expect(command.config_content).to eq 'the-yaml' + end + end + + context 'when bridge job does not have downstream yaml' do + before do + allow(bridge).to receive(:yaml_for_downstream).and_return(nil) + end + + it 'returns the next available source' do + subject.perform! + + expect(pipeline.config_source).to eq 'auto_devops_source' + template = Gitlab::Template::GitlabCiYmlTemplate.find('Beta/Auto-DevOps') + expect(command.config_content).to eq(template.content) + end + end + end + context 'when config is defined in a custom path in the repository' do let(:ci_config_path) { 'path/to/config.yml' } @@ -29,6 +65,7 @@ describe Gitlab::Ci::Pipeline::Chain::Config::Content do subject.perform! expect(pipeline.config_source).to eq 'repository_source' + expect(pipeline.pipeline_config).to be_nil expect(command.config_content).to eq('the-content') end end @@ -40,7 +77,8 @@ describe Gitlab::Ci::Pipeline::Chain::Config::Content do subject.perform! expect(pipeline.config_source).to eq 'auto_devops_source' - template = Gitlab::Template::GitlabCiYmlTemplate.find('Auto-DevOps') + expect(pipeline.pipeline_config).to be_nil + template = Gitlab::Template::GitlabCiYmlTemplate.find('Beta/Auto-DevOps') expect(command.config_content).to eq(template.content) end end @@ -52,7 +90,8 @@ describe Gitlab::Ci::Pipeline::Chain::Config::Content do subject.perform! expect(pipeline.config_source).to eq 'auto_devops_source' - template = Gitlab::Template::GitlabCiYmlTemplate.find('Auto-DevOps') + expect(pipeline.pipeline_config).to be_nil + template = Gitlab::Template::GitlabCiYmlTemplate.find('Beta/Auto-DevOps') expect(command.config_content).to eq(template.content) end end @@ -71,6 +110,7 @@ describe Gitlab::Ci::Pipeline::Chain::Config::Content do subject.perform! expect(pipeline.config_source).to eq 'repository_source' + expect(pipeline.pipeline_config).to be_nil expect(command.config_content).to eq('the-content') end end @@ -82,12 +122,34 @@ describe Gitlab::Ci::Pipeline::Chain::Config::Content do expect(project).to receive(:auto_devops_enabled?).and_return(true) end - it 'returns the content of AutoDevops template' do - subject.perform! + context 'when beta is enabled' do + before do + stub_feature_flags(auto_devops_beta: true) + end - expect(pipeline.config_source).to eq 'auto_devops_source' - template = Gitlab::Template::GitlabCiYmlTemplate.find('Auto-DevOps') - expect(command.config_content).to eq(template.content) + it 'returns the content of AutoDevops template' do + subject.perform! + + expect(pipeline.config_source).to eq 'auto_devops_source' + expect(pipeline.pipeline_config).to be_nil + template = Gitlab::Template::GitlabCiYmlTemplate.find('Beta/Auto-DevOps') + expect(command.config_content).to eq(template.content) + end + end + + context 'when beta is disabled' do + before do + stub_feature_flags(auto_devops_beta: false) + end + + it 'returns the content of AutoDevops template' do + subject.perform! + + expect(pipeline.config_source).to eq 'auto_devops_source' + expect(pipeline.pipeline_config).to be_nil + template = Gitlab::Template::GitlabCiYmlTemplate.find('Auto-DevOps') + expect(command.config_content).to eq(template.content) + end end end @@ -102,14 +164,39 @@ describe Gitlab::Ci::Pipeline::Chain::Config::Content do subject.perform! expect(pipeline.config_source).to eq('unknown_source') + expect(pipeline.pipeline_config).to be_nil expect(command.config_content).to be_nil expect(pipeline.errors.full_messages).to include('Missing CI config file') end end end + context 'when bridge job is passed in as parameter' do + let(:ci_config_path) { nil } + let(:bridge) { create(:ci_bridge) } + + before do + command.bridge = bridge + allow(bridge).to receive(:yaml_for_downstream).and_return('the-yaml') + end + + it 'returns the content already available in command' do + subject.perform! + + expect(pipeline.config_source).to eq 'bridge_source' + expect(command.config_content).to eq 'the-yaml' + end + end + context 'when config is defined in a custom path in the repository' do let(:ci_config_path) { 'path/to/config.yml' } + let(:config_content_result) do + <<~EOY + --- + include: + - local: #{ci_config_path} + EOY + end before do expect(project.repository) @@ -122,47 +209,59 @@ describe Gitlab::Ci::Pipeline::Chain::Config::Content do subject.perform! expect(pipeline.config_source).to eq 'repository_source' - expect(command.config_content).to eq(<<~EOY) - --- - include: - - local: #{ci_config_path} - EOY + expect(pipeline.pipeline_config.content).to eq(config_content_result) + expect(command.config_content).to eq(config_content_result) end end context 'when config is defined remotely' do let(:ci_config_path) { 'http://example.com/path/to/ci/config.yml' } + let(:config_content_result) do + <<~EOY + --- + include: + - remote: #{ci_config_path} + EOY + end it 'builds root config including the remote config' do subject.perform! expect(pipeline.config_source).to eq 'remote_source' - expect(command.config_content).to eq(<<~EOY) - --- - include: - - remote: #{ci_config_path} - EOY + expect(pipeline.pipeline_config.content).to eq(config_content_result) + expect(command.config_content).to eq(config_content_result) end end context 'when config is defined in a separate repository' do let(:ci_config_path) { 'path/to/.gitlab-ci.yml@another-group/another-repo' } - - it 'builds root config including the path to another repository' do - subject.perform! - - expect(pipeline.config_source).to eq 'external_project_source' - expect(command.config_content).to eq(<<~EOY) + let(:config_content_result) do + <<~EOY --- include: - project: another-group/another-repo file: path/to/.gitlab-ci.yml EOY end + + it 'builds root config including the path to another repository' do + subject.perform! + + expect(pipeline.config_source).to eq 'external_project_source' + expect(pipeline.pipeline_config.content).to eq(config_content_result) + expect(command.config_content).to eq(config_content_result) + end end context 'when config is defined in the default .gitlab-ci.yml' do let(:ci_config_path) { nil } + let(:config_content_result) do + <<~EOY + --- + include: + - local: ".gitlab-ci.yml" + EOY + end before do expect(project.repository) @@ -175,30 +274,59 @@ describe Gitlab::Ci::Pipeline::Chain::Config::Content do subject.perform! expect(pipeline.config_source).to eq 'repository_source' - expect(command.config_content).to eq(<<~EOY) - --- - include: - - local: ".gitlab-ci.yml" - EOY + expect(pipeline.pipeline_config.content).to eq(config_content_result) + expect(command.config_content).to eq(config_content_result) end end context 'when config is the Auto-Devops template' do let(:ci_config_path) { nil } + let(:config_content_result) do + <<~EOY + --- + include: + - template: Beta/Auto-DevOps.gitlab-ci.yml + EOY + end before do expect(project).to receive(:auto_devops_enabled?).and_return(true) end - it 'builds root config including the auto-devops template' do - subject.perform! + context 'when beta is enabled' do + before do + stub_feature_flags(auto_devops_beta: true) + end - expect(pipeline.config_source).to eq 'auto_devops_source' - expect(command.config_content).to eq(<<~EOY) - --- - include: - - template: Auto-DevOps.gitlab-ci.yml - EOY + it 'builds root config including the auto-devops template' do + subject.perform! + + expect(pipeline.config_source).to eq 'auto_devops_source' + expect(pipeline.pipeline_config.content).to eq(config_content_result) + expect(command.config_content).to eq(config_content_result) + end + end + + context 'when beta is disabled' do + before do + stub_feature_flags(auto_devops_beta: false) + end + + let(:config_content_result) do + <<~EOY + --- + include: + - template: Auto-DevOps.gitlab-ci.yml + EOY + end + + it 'builds root config including the auto-devops template' do + subject.perform! + + expect(pipeline.config_source).to eq 'auto_devops_source' + expect(pipeline.pipeline_config.content).to eq(config_content_result) + expect(command.config_content).to eq(config_content_result) + end end end @@ -213,6 +341,7 @@ describe Gitlab::Ci::Pipeline::Chain::Config::Content do subject.perform! expect(pipeline.config_source).to eq('unknown_source') + expect(pipeline.pipeline_config).to be_nil expect(command.config_content).to be_nil expect(pipeline.errors.full_messages).to include('Missing CI config file') end diff --git a/spec/lib/gitlab/ci/pipeline/chain/validate/abilities_spec.rb b/spec/lib/gitlab/ci/pipeline/chain/validate/abilities_spec.rb index ac3704339558e069ec184772235358d8e389bd67..24d3beb35b9b221f7e5ab22445cd86acda74d0d4 100644 --- a/spec/lib/gitlab/ci/pipeline/chain/validate/abilities_spec.rb +++ b/spec/lib/gitlab/ci/pipeline/chain/validate/abilities_spec.rb @@ -76,45 +76,8 @@ describe Gitlab::Ci::Pipeline::Chain::Validate::Abilities do end end - context 'when pipeline triggered by legacy trigger' do - let(:user) { nil } - let(:trigger_request) do - build_stubbed(:ci_trigger_request, trigger: build_stubbed(:ci_trigger, owner: nil)) - end - - context 'when :use_legacy_pipeline_triggers feature flag is enabled' do - before do - stub_feature_flags(use_legacy_pipeline_triggers: true) - step.perform! - end - - it 'allows legacy triggers to create a pipeline' do - expect(pipeline).to be_valid - end - - it 'does not break the chain' do - expect(step.break?).to eq false - end - end - - context 'when :use_legacy_pipeline_triggers feature flag is disabled' do - before do - stub_feature_flags(use_legacy_pipeline_triggers: false) - step.perform! - end - - it 'prevents legacy triggers from creating a pipeline' do - expect(pipeline.errors.to_a).to include /Trigger token is invalid/ - end - - it 'breaks the pipeline builder chain' do - expect(step.break?).to eq true - end - end - end - - describe '#allowed_to_create?' do - subject { step.allowed_to_create? } + describe '#allowed_to_write_ref?' do + subject { step.send(:allowed_to_write_ref?) } context 'when user is a developer' do before do diff --git a/spec/lib/gitlab/ci/pipeline/seed/build/resource_group_spec.rb b/spec/lib/gitlab/ci/pipeline/seed/build/resource_group_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..bf6985156d3680192b5a3dea1ae2df2a2445926b --- /dev/null +++ b/spec/lib/gitlab/ci/pipeline/seed/build/resource_group_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::Ci::Pipeline::Seed::Build::ResourceGroup do + let_it_be(:project) { create(:project) } + let(:job) { build(:ci_build, project: project) } + let(:seed) { described_class.new(job, resource_group_key) } + + describe '#to_resource' do + subject { seed.to_resource } + + context 'when resource group key is specified' do + let(:resource_group_key) { 'iOS' } + + it 'returns a resource group object' do + is_expected.to be_a(Ci::ResourceGroup) + expect(subject.key).to eq('iOS') + end + + context 'when environment has an invalid URL' do + let(:resource_group_key) { ':::' } + + it 'returns nothing' do + is_expected.to be_nil + end + end + + context 'when there is a resource group already' do + let!(:resource_group) { create(:ci_resource_group, project: project, key: 'iOS') } + + it 'does not create a new resource group' do + expect { subject }.not_to change { Ci::ResourceGroup.count } + end + end + end + + context 'when resource group key is nil' do + let(:resource_group_key) { nil } + + it 'returns nothing' do + is_expected.to be_nil + end + end + end +end diff --git a/spec/lib/gitlab/ci/pipeline/seed/build_spec.rb b/spec/lib/gitlab/ci/pipeline/seed/build_spec.rb index 2ae513aea1b5154ea3638bad9c09ab86daca544c..5526ec9e16f877df9c1acae8599e11264b08e610 100644 --- a/spec/lib/gitlab/ci/pipeline/seed/build_spec.rb +++ b/spec/lib/gitlab/ci/pipeline/seed/build_spec.rb @@ -231,6 +231,15 @@ describe Gitlab::Ci::Pipeline::Seed::Build do end end end + + context 'when job belongs to a resource group' do + let(:attributes) { { name: 'rspec', ref: 'master', resource_group_key: 'iOS' } } + + it 'returns a job with resource group' do + expect(subject.resource_group).not_to be_nil + expect(subject.resource_group.key).to eq('iOS') + end + end end context 'when job is a bridge' do diff --git a/spec/lib/gitlab/ci/pipeline/seed/deployment_spec.rb b/spec/lib/gitlab/ci/pipeline/seed/deployment_spec.rb index 4e63f60ea6bc1d6d0cf3e156b88d724f83c62be4..90f4b06cea004a27f8045cb5c16bee7dd384975c 100644 --- a/spec/lib/gitlab/ci/pipeline/seed/deployment_spec.rb +++ b/spec/lib/gitlab/ci/pipeline/seed/deployment_spec.rb @@ -3,8 +3,13 @@ require 'spec_helper' describe Gitlab::Ci::Pipeline::Seed::Deployment do - let_it_be(:project) { create(:project) } - let(:job) { build(:ci_build, project: project) } + let_it_be(:project) { create(:project, :repository) } + let(:pipeline) do + create(:ci_pipeline, project: project, + sha: 'b83d6e391c22777fca1ed3012fce84f633d7fed0') + end + + let(:job) { build(:ci_build, project: project, pipeline: pipeline) } let(:seed) { described_class.new(job) } let(:attributes) { {} } diff --git a/spec/lib/gitlab/ci/status/external/factory_spec.rb b/spec/lib/gitlab/ci/status/external/factory_spec.rb index 9d7dfc4284889be438dd1db4041f395f9118825b..9c11e42fc5a250185dc37721d51f96d04dedb493 100644 --- a/spec/lib/gitlab/ci/status/external/factory_spec.rb +++ b/spec/lib/gitlab/ci/status/external/factory_spec.rb @@ -22,7 +22,7 @@ describe Gitlab::Ci::Status::External::Factory do end let(:expected_status) do - Gitlab::Ci::Status.const_get(simple_status.capitalize, false) + Gitlab::Ci::Status.const_get(simple_status.to_s.camelize, false) end it "fabricates a core status #{simple_status}" do diff --git a/spec/lib/gitlab/ci/status/factory_spec.rb b/spec/lib/gitlab/ci/status/factory_spec.rb index c6d7a1ec5d91e9640286c887ce316f6784aaf1be..219eb53d9df141f15841ac1a5486bf3b1ecbafe0 100644 --- a/spec/lib/gitlab/ci/status/factory_spec.rb +++ b/spec/lib/gitlab/ci/status/factory_spec.rb @@ -13,7 +13,7 @@ describe Gitlab::Ci::Status::Factory do let(:resource) { double('resource', status: simple_status) } let(:expected_status) do - Gitlab::Ci::Status.const_get(simple_status.capitalize, false) + Gitlab::Ci::Status.const_get(simple_status.to_s.camelize, false) end it "fabricates a core status #{simple_status}" do diff --git a/spec/lib/gitlab/ci/status/pipeline/factory_spec.rb b/spec/lib/gitlab/ci/status/pipeline/factory_spec.rb index 3acc767ab7a883999be8cf004b9296761b7a63bd..838154759cbf8bd01b427b7efa04848a94c3c09f 100644 --- a/spec/lib/gitlab/ci/status/pipeline/factory_spec.rb +++ b/spec/lib/gitlab/ci/status/pipeline/factory_spec.rb @@ -18,7 +18,7 @@ describe Gitlab::Ci::Status::Pipeline::Factory do let(:pipeline) { create(:ci_pipeline, status: simple_status) } let(:expected_status) do - Gitlab::Ci::Status.const_get(simple_status.capitalize, false) + Gitlab::Ci::Status.const_get(simple_status.camelize, false) end it "matches correct core status for #{simple_status}" do diff --git a/spec/lib/gitlab/ci/status/stage/factory_spec.rb b/spec/lib/gitlab/ci/status/stage/factory_spec.rb index dcb537121576c4d32fd8c1ebe2221cab95a69596..317756ea13c7aa317ad581ab6d5bb92d0213ed37 100644 --- a/spec/lib/gitlab/ci/status/stage/factory_spec.rb +++ b/spec/lib/gitlab/ci/status/stage/factory_spec.rb @@ -34,7 +34,7 @@ describe Gitlab::Ci::Status::Stage::Factory do it "fabricates a core status #{core_status}" do expect(status).to be_a( - Gitlab::Ci::Status.const_get(core_status.capitalize, false)) + Gitlab::Ci::Status.const_get(core_status.camelize, false)) end it 'extends core status with common stage methods' do diff --git a/spec/lib/gitlab/ci/status/waiting_for_resource_spec.rb b/spec/lib/gitlab/ci/status/waiting_for_resource_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..ed00dac85609407b4801a509779c4ba8624f41b4 --- /dev/null +++ b/spec/lib/gitlab/ci/status/waiting_for_resource_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::Ci::Status::WaitingForResource do + subject do + described_class.new(double('subject'), double('user')) + end + + describe '#text' do + it { expect(subject.text).to eq 'waiting' } + end + + describe '#label' do + it { expect(subject.label).to eq 'waiting for resource' } + end + + describe '#icon' do + it { expect(subject.icon).to eq 'status_pending' } + end + + describe '#favicon' do + it { expect(subject.favicon).to eq 'favicon_pending' } + end + + describe '#group' do + it { expect(subject.group).to eq 'waiting-for-resource' } + end +end diff --git a/spec/lib/gitlab/ci/templates/auto_devops_gitlab_ci_yaml_spec.rb b/spec/lib/gitlab/ci/templates/auto_devops_gitlab_ci_yaml_spec.rb index c2f9930056ab3741603bd14156a5910e3bf9a4c8..12600d97b2f74fd37bc6bb5d407d5bace46a9058 100644 --- a/spec/lib/gitlab/ci/templates/auto_devops_gitlab_ci_yaml_spec.rb +++ b/spec/lib/gitlab/ci/templates/auto_devops_gitlab_ci_yaml_spec.rb @@ -9,7 +9,7 @@ describe 'Auto-DevOps.gitlab-ci.yml' do let(:user) { create(:admin) } let(:default_branch) { 'master' } let(:pipeline_branch) { default_branch } - let(:project) { create(:project, :custom_repo, files: { 'README.md' => '' }) } + let(:project) { create(:project, :auto_devops, :custom_repo, files: { 'README.md' => '' }) } let(:service) { Ci::CreatePipelineService.new(project, user, ref: pipeline_branch ) } let(:pipeline) { service.execute!(:push) } let(:build_names) { pipeline.builds.pluck(:name) } @@ -107,4 +107,52 @@ describe 'Auto-DevOps.gitlab-ci.yml' do end end end + + describe 'build-pack detection' do + using RSpec::Parameterized::TableSyntax + + where(:case_name, :files, :variables, :include_build_names, :not_include_build_names) do + 'No match' | { 'README.md' => '' } | {} | %w() | %w(build test) + 'Buildpack' | { 'README.md' => '' } | { 'BUILDPACK_URL' => 'http://example.com' } | %w(build test) | %w() + 'Explicit set' | { 'README.md' => '' } | { 'AUTO_DEVOPS_EXPLICITLY_ENABLED' => '1' } | %w(build test) | %w() + 'Explicit unset' | { 'README.md' => '' } | { 'AUTO_DEVOPS_EXPLICITLY_ENABLED' => '0' } | %w() | %w(build test) + 'Dockerfile' | { 'Dockerfile' => '' } | {} | %w(build test) | %w() + 'Clojure' | { 'project.clj' => '' } | {} | %w(build test) | %w() + 'Go modules' | { 'go.mod' => '' } | {} | %w(build test) | %w() + 'Go gb' | { 'src/gitlab.com/gopackage.go' => '' } | {} | %w(build test) | %w() + 'Gradle' | { 'gradlew' => '' } | {} | %w(build test) | %w() + 'Java' | { 'pom.xml' => '' } | {} | %w(build test) | %w() + 'Multi-buildpack' | { '.buildpacks' => '' } | {} | %w(build test) | %w() + 'NodeJS' | { 'package.json' => '' } | {} | %w(build test) | %w() + 'PHP' | { 'composer.json' => '' } | {} | %w(build test) | %w() + 'Play' | { 'conf/application.conf' => '' } | {} | %w(build test) | %w() + 'Python' | { 'Pipfile' => '' } | {} | %w(build test) | %w() + 'Ruby' | { 'Gemfile' => '' } | {} | %w(build test) | %w() + 'Scala' | { 'build.sbt' => '' } | {} | %w(build test) | %w() + 'Static' | { '.static' => '' } | {} | %w(build test) | %w() + end + + with_them do + subject(:template) { Gitlab::Template::GitlabCiYmlTemplate.find('Beta/Auto-DevOps') } + + let(:user) { create(:admin) } + let(:project) { create(:project, :custom_repo, files: files) } + let(:service) { Ci::CreatePipelineService.new(project, user, ref: 'master' ) } + let(:pipeline) { service.execute(:push) } + let(:build_names) { pipeline.builds.pluck(:name) } + + before do + stub_ci_pipeline_yaml_file(template.content) + allow_any_instance_of(Ci::BuildScheduleWorker).to receive(:perform).and_return(true) + variables.each do |(key, value)| + create(:ci_variable, project: project, key: key, value: value) + end + end + + it 'creates a pipeline with the expected jobs' do + expect(build_names).to include(*include_build_names) + expect(build_names).not_to include(*not_include_build_names) + end + end + end end diff --git a/spec/lib/gitlab/ci/trace_spec.rb b/spec/lib/gitlab/ci/trace_spec.rb index f7bc5686b683ee3e6646b36f043c250a351adb04..574c2b73722332329d4676ca19aa3c19987945e6 100644 --- a/spec/lib/gitlab/ci/trace_spec.rb +++ b/spec/lib/gitlab/ci/trace_spec.rb @@ -26,4 +26,66 @@ describe Gitlab::Ci::Trace, :clean_gitlab_redis_shared_state do it_behaves_like 'trace with enabled live trace feature' end + + describe '#update_interval' do + context 'it is not being watched' do + it 'returns 30 seconds' do + expect(trace.update_interval).to eq(30.seconds) + end + end + + context 'it is being watched' do + before do + trace.being_watched! + end + + it 'returns 3 seconds' do + expect(trace.update_interval).to eq(3.seconds) + end + end + end + + describe '#being_watched!' do + let(:cache_key) { "gitlab:ci:trace:#{build.id}:watched" } + + it 'sets gitlab:ci:trace:<job.id>:watched in redis' do + trace.being_watched! + + result = Gitlab::Redis::SharedState.with do |redis| + redis.exists(cache_key) + end + + expect(result).to eq(true) + end + + it 'updates the expiry of gitlab:ci:trace:<job.id>:watched in redis', :clean_gitlab_redis_shared_state do + Gitlab::Redis::SharedState.with do |redis| + redis.set(cache_key, true, ex: 4.seconds) + end + + expect do + trace.being_watched! + end.to change { Gitlab::Redis::SharedState.with { |redis| redis.pttl(cache_key) } } + end + end + + describe '#being_watched?' do + context 'gitlab:ci:trace:<job.id>:watched in redis is set', :clean_gitlab_redis_shared_state do + before do + Gitlab::Redis::SharedState.with do |redis| + redis.set("gitlab:ci:trace:#{build.id}:watched", true) + end + end + + it 'returns true' do + expect(trace.being_watched?).to be(true) + end + end + + context 'gitlab:ci:trace:<job.id>:watched in redis is not set' do + it 'returns false' do + expect(trace.being_watched?).to be(false) + end + end + end end diff --git a/spec/lib/gitlab/ci/yaml_processor_spec.rb b/spec/lib/gitlab/ci/yaml_processor_spec.rb index fea8073f999a77e87c051a0ce90af1db8af6fc7e..11168a969fc2029059a1734dfd9087e793ae1cc4 100644 --- a/spec/lib/gitlab/ci/yaml_processor_spec.rb +++ b/spec/lib/gitlab/ci/yaml_processor_spec.rb @@ -241,6 +241,21 @@ module Gitlab end end end + + describe 'resource group' do + context 'when resource group is defined' do + let(:config) do + YAML.dump(rspec: { + script: 'test', + resource_group: 'iOS' + }) + end + + it 'has the attributes' do + expect(subject[:resource_group_key]).to eq 'iOS' + end + end + end end describe '#stages_attributes' do @@ -1270,6 +1285,59 @@ module Gitlab end end + describe "release" do + let(:processor) { Gitlab::Ci::YamlProcessor.new(YAML.dump(config)) } + let(:config) do + { + stages: ["build", "test", "release"], # rubocop:disable Style/WordArray + release: { + stage: "release", + only: ["tags"], + script: ["make changelog | tee release_changelog.txt"], + release: { + tag_name: "$CI_COMMIT_TAG", + name: "Release $CI_TAG_NAME", + description: "./release_changelog.txt", + assets: { + links: [ + { + name: "cool-app.zip", + url: "http://my.awesome.download.site/1.0-$CI_COMMIT_SHORT_SHA.zip" + }, + { + name: "cool-app.exe", + url: "http://my.awesome.download.site/1.0-$CI_COMMIT_SHORT_SHA.exe" + } + ] + } + } + } + } + end + + context 'with feature flag active' do + before do + stub_feature_flags(ci_release_generation: true) + end + + it "returns release info" do + expect(processor.stage_builds_attributes('release').first[:options]) + .to eq(config[:release].except(:stage, :only)) + end + end + + context 'with feature flag inactive' do + before do + stub_feature_flags(ci_release_generation: false) + end + + it 'raises error' do + expect { processor }.to raise_error( + 'jobs:release config release features are not enabled: release') + end + end + end + describe '#environment' do let(:config) do { @@ -1667,6 +1735,39 @@ module Gitlab it { expect { subject }.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, 'jobs:test1 dependencies the build2 should be part of needs') } end + + context 'needs with a Hash type and dependencies with a string type that are mismatching' do + let(:needs) do + [ + "build1", + { job: "build2" } + ] + end + let(:dependencies) { %w(build3) } + + it { expect { subject }.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, 'jobs:test1 dependencies the build3 should be part of needs') } + end + + context 'needs with an array type and dependency with a string type' do + let(:needs) { %w(build1) } + let(:dependencies) { 'deploy' } + + it { expect { subject }.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, 'jobs:test1 dependencies should be an array of strings') } + end + + context 'needs with a string type and dependency with an array type' do + let(:needs) { 'build1' } + let(:dependencies) { %w(deploy) } + + it { expect { subject }.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, 'jobs:test1:needs config can only be a hash or an array') } + end + + context 'needs with a Hash type and dependency with a string type' do + let(:needs) { { job: 'build1' } } + let(:dependencies) { 'deploy' } + + it { expect { subject }.to raise_error(Gitlab::Ci::YamlProcessor::ValidationError, 'jobs:test1 dependencies should be an array of strings') } + end end context 'with when/rules conflict' do diff --git a/spec/lib/gitlab/closing_issue_extractor_spec.rb b/spec/lib/gitlab/closing_issue_extractor_spec.rb index fdd01f58c9d03b65145b7df15c4d4011289cbebd..510876a59453b0aa1b4ac40a06a5fc3731d06475 100644 --- a/spec/lib/gitlab/closing_issue_extractor_spec.rb +++ b/spec/lib/gitlab/closing_issue_extractor_spec.rb @@ -438,6 +438,17 @@ describe Gitlab::ClosingIssueExtractor do .to match_array([issue]) end end + + context "with autoclose referenced issues disabled" do + before do + project.update!(autoclose_referenced_issues: false) + end + + it do + message = "Awesome commit (Closes #{reference})" + expect(subject.closed_by_message(message)).to eq([]) + end + end end def urls diff --git a/spec/lib/gitlab/config/entry/attributable_spec.rb b/spec/lib/gitlab/config/entry/attributable_spec.rb index 6b548d5c4a80182ea7aa78ad7bd56a05e36fcd7e..bc29a194181d9643fde2f8d3d813dceb7678857c 100644 --- a/spec/lib/gitlab/config/entry/attributable_spec.rb +++ b/spec/lib/gitlab/config/entry/attributable_spec.rb @@ -59,7 +59,7 @@ describe Gitlab::Config::Entry::Attributable do end end - expectation.to raise_error(ArgumentError, 'Method already defined!') + expectation.to raise_error(ArgumentError, 'Method already defined: length') end end end diff --git a/spec/lib/gitlab/cycle_analytics/production_stage_spec.rb b/spec/lib/gitlab/cycle_analytics/production_stage_spec.rb index 4d0cc91a318a1cb820eac2ddf34fa9dc9013915c..eceea47498824bd6086aabd2f5e63e74a02ebda2 100644 --- a/spec/lib/gitlab/cycle_analytics/production_stage_spec.rb +++ b/spec/lib/gitlab/cycle_analytics/production_stage_spec.rb @@ -4,7 +4,7 @@ require 'spec_helper' require 'lib/gitlab/cycle_analytics/shared_stage_spec' describe Gitlab::CycleAnalytics::ProductionStage do - let(:stage_name) { :production } + let(:stage_name) { 'Total' } it_behaves_like 'base stage' end diff --git a/spec/lib/gitlab/danger/changelog_spec.rb b/spec/lib/gitlab/danger/changelog_spec.rb index 689957993ec6b2320f8315f17eb3781353ad9926..64f87ec8cd3a122c03d4c7c1228fb4e8efb809f1 100644 --- a/spec/lib/gitlab/danger/changelog_spec.rb +++ b/spec/lib/gitlab/danger/changelog_spec.rb @@ -106,18 +106,6 @@ describe Gitlab::Danger::Changelog do end end - describe '#sanitized_mr_title' do - subject { changelog.sanitized_mr_title } - - [ - 'WIP: My MR title', - 'My MR title' - ].each do |mr_title| - let(:mr_json) { { "title" => mr_title } } - it { is_expected.to eq("My MR title") } - end - end - describe '#ee_changelog?' do context 'is ee changelog' do [ diff --git a/spec/lib/gitlab/danger/commit_linter_spec.rb b/spec/lib/gitlab/danger/commit_linter_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..0cf7ac64e434fe2da1497883553565fdf4af97ee --- /dev/null +++ b/spec/lib/gitlab/danger/commit_linter_spec.rb @@ -0,0 +1,315 @@ +# frozen_string_literal: true + +require 'fast_spec_helper' +require 'rspec-parameterized' +require_relative 'danger_spec_helper' + +require 'gitlab/danger/commit_linter' + +describe Gitlab::Danger::CommitLinter do + using RSpec::Parameterized::TableSyntax + + let(:total_files_changed) { 2 } + let(:total_lines_changed) { 10 } + let(:stats) { { total: { files: total_files_changed, lines: total_lines_changed } } } + let(:diff_parent) { Struct.new(:stats).new(stats) } + let(:commit_class) do + Struct.new(:message, :sha, :diff_parent) + end + let(:commit_message) { 'A commit message' } + let(:commit_sha) { 'abcd1234' } + let(:commit) { commit_class.new(commit_message, commit_sha, diff_parent) } + + subject(:commit_linter) { described_class.new(commit) } + + describe '#fixup?' do + where(:commit_message, :is_fixup) do + 'A commit message' | false + 'fixup!' | true + 'fixup! A commit message' | true + 'squash!' | true + 'squash! A commit message' | true + end + + with_them do + it 'is true when commit message starts with "fixup!" or "squash!"' do + expect(commit_linter.fixup?).to be(is_fixup) + end + end + end + + describe '#suggestion?' do + where(:commit_message, :is_suggestion) do + 'A commit message' | false + 'Apply suggestion to' | true + 'Apply suggestion to "A commit message"' | true + end + + with_them do + it 'is true when commit message starts with "Apply suggestion to"' do + expect(commit_linter.suggestion?).to be(is_suggestion) + end + end + end + + describe '#merge?' do + where(:commit_message, :is_merge) do + 'A commit message' | false + 'Merge branch' | true + 'Merge branch "A commit message"' | true + end + + with_them do + it 'is true when commit message starts with "Merge branch"' do + expect(commit_linter.merge?).to be(is_merge) + end + end + end + + describe '#revert?' do + where(:commit_message, :is_revert) do + 'A commit message' | false + 'Revert' | false + 'Revert "' | true + 'Revert "A commit message"' | true + end + + with_them do + it 'is true when commit message starts with "Revert \""' do + expect(commit_linter.revert?).to be(is_revert) + end + end + end + + describe '#multi_line?' do + where(:commit_message, :is_multi_line) do + "A commit message" | false + "A commit message\n" | false + "A commit message\n\n" | false + "A commit message\n\nWith details" | true + end + + with_them do + it 'is true when commit message contains details' do + expect(commit_linter.multi_line?).to be(is_multi_line) + end + end + end + + describe '#failed?' do + context 'with no failures' do + it { expect(commit_linter).not_to be_failed } + end + + context 'with failures' do + before do + commit_linter.add_problem(:details_line_too_long) + end + + it { expect(commit_linter).to be_failed } + end + end + + describe '#add_problem' do + it 'stores messages in #failures' do + commit_linter.add_problem(:details_line_too_long) + + expect(commit_linter.problems).to eq({ details_line_too_long: described_class::PROBLEMS[:details_line_too_long] }) + end + end + + shared_examples 'a valid commit' do + it 'does not have any problem' do + commit_linter.lint + + expect(commit_linter.problems).to be_empty + end + end + + describe '#lint' do + describe 'subject' do + context 'when subject valid' do + it_behaves_like 'a valid commit' + end + + context 'when subject is too short' do + let(:commit_message) { 'A B' } + + it 'adds a problem' do + expect(commit_linter).to receive(:add_problem).with(:subject_too_short, described_class::DEFAULT_SUBJECT_DESCRIPTION) + + commit_linter.lint + end + end + + context 'when subject is too long' do + let(:commit_message) { 'A B ' + 'C' * described_class::MAX_LINE_LENGTH } + + it 'adds a problem' do + expect(commit_linter).to receive(:add_problem).with(:subject_too_long, described_class::DEFAULT_SUBJECT_DESCRIPTION) + + commit_linter.lint + end + end + + context 'when subject is too short and too long' do + let(:commit_message) { 'A ' + 'B' * described_class::MAX_LINE_LENGTH } + + it 'adds a problem' do + expect(commit_linter).to receive(:add_problem).with(:subject_too_short, described_class::DEFAULT_SUBJECT_DESCRIPTION) + expect(commit_linter).to receive(:add_problem).with(:subject_too_long, described_class::DEFAULT_SUBJECT_DESCRIPTION) + + commit_linter.lint + end + end + + context 'when subject is above warning' do + let(:commit_message) { 'A B ' + 'C' * described_class::WARN_SUBJECT_LENGTH } + + it 'adds a problem' do + expect(commit_linter).to receive(:add_problem).with(:subject_above_warning, described_class::DEFAULT_SUBJECT_DESCRIPTION) + + commit_linter.lint + end + end + + context 'when subject starts with lowercase' do + let(:commit_message) { 'a B C' } + + it 'adds a problem' do + expect(commit_linter).to receive(:add_problem).with(:subject_starts_with_lowercase, described_class::DEFAULT_SUBJECT_DESCRIPTION) + + commit_linter.lint + end + end + + context 'when subject ands with a period' do + let(:commit_message) { 'A B C.' } + + it 'adds a problem' do + expect(commit_linter).to receive(:add_problem).with(:subject_ends_with_a_period, described_class::DEFAULT_SUBJECT_DESCRIPTION) + + commit_linter.lint + end + end + end + + describe 'separator' do + context 'when separator is missing' do + let(:commit_message) { "A B C\n" } + + it_behaves_like 'a valid commit' + end + + context 'when separator is a blank line' do + let(:commit_message) { "A B C\n\nMore details." } + + it_behaves_like 'a valid commit' + end + + context 'when separator is missing' do + let(:commit_message) { "A B C\nMore details." } + + it 'adds a problem' do + expect(commit_linter).to receive(:add_problem).with(:separator_missing) + + commit_linter.lint + end + end + end + + describe 'details' do + context 'when details are valid' do + let(:commit_message) { "A B C\n\nMore details." } + + it_behaves_like 'a valid commit' + end + + context 'when no details are given and many files are changed' do + let(:total_files_changed) { described_class::MAX_CHANGED_FILES_IN_COMMIT + 1 } + + it_behaves_like 'a valid commit' + end + + context 'when no details are given and many lines are changed' do + let(:total_lines_changed) { described_class::MAX_CHANGED_LINES_IN_COMMIT + 1 } + + it_behaves_like 'a valid commit' + end + + context 'when no details are given and many files and lines are changed' do + let(:total_files_changed) { described_class::MAX_CHANGED_FILES_IN_COMMIT + 1 } + let(:total_lines_changed) { described_class::MAX_CHANGED_LINES_IN_COMMIT + 1 } + + it 'adds a problem' do + expect(commit_linter).to receive(:add_problem).with(:details_too_many_changes) + + commit_linter.lint + end + end + + context 'when details exceeds the max line length' do + let(:commit_message) { "A B C\n\n" + 'D' * (described_class::MAX_LINE_LENGTH + 1) } + + it 'adds a problem' do + expect(commit_linter).to receive(:add_problem).with(:details_line_too_long) + + commit_linter.lint + end + end + + context 'when details exceeds the max line length including a URL' do + let(:commit_message) { "A B C\n\nhttps://gitlab.com" + 'D' * described_class::MAX_LINE_LENGTH } + + it_behaves_like 'a valid commit' + end + end + + describe 'message' do + context 'when message includes a text emoji' do + let(:commit_message) { "A commit message :+1:" } + + it 'adds a problem' do + expect(commit_linter).to receive(:add_problem).with(:message_contains_text_emoji) + + commit_linter.lint + end + end + + context 'when message includes a unicode emoji' do + let(:commit_message) { "A commit message 🚀" } + + it 'adds a problem' do + expect(commit_linter).to receive(:add_problem).with(:message_contains_unicode_emoji) + + commit_linter.lint + end + end + + context 'when message includes a short reference' do + [ + 'A commit message to fix #1234', + 'A commit message to fix !1234', + 'A commit message to fix &1234', + 'A commit message to fix %1234', + 'A commit message to fix gitlab#1234', + 'A commit message to fix gitlab!1234', + 'A commit message to fix gitlab&1234', + 'A commit message to fix gitlab%1234', + 'A commit message to fix gitlab-org/gitlab#1234', + 'A commit message to fix gitlab-org/gitlab!1234', + 'A commit message to fix gitlab-org/gitlab&1234', + 'A commit message to fix gitlab-org/gitlab%1234' + ].each do |message| + let(:commit_message) { message } + + it 'adds a problem' do + expect(commit_linter).to receive(:add_problem).with(:message_contains_short_reference) + + commit_linter.lint + end + end + end + end + end +end diff --git a/spec/lib/gitlab/danger/emoji_checker_spec.rb b/spec/lib/gitlab/danger/emoji_checker_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..0cdc18ce626a50f5317e378d88725cbe0f5d8c57 --- /dev/null +++ b/spec/lib/gitlab/danger/emoji_checker_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require 'fast_spec_helper' +require 'rspec-parameterized' + +require 'gitlab/danger/emoji_checker' + +describe Gitlab::Danger::EmojiChecker do + using RSpec::Parameterized::TableSyntax + + describe '#includes_text_emoji?' do + where(:text, :includes_emoji) do + 'Hello World!' | false + ':+1:' | true + 'Hello World! :+1:' | true + end + + with_them do + it 'is true when text includes a text emoji' do + expect(subject.includes_text_emoji?(text)).to be(includes_emoji) + end + end + end + + describe '#includes_unicode_emoji?' do + where(:text, :includes_emoji) do + 'Hello World!' | false + '🚀' | true + 'Hello World! 🚀' | true + end + + with_them do + it 'is true when text includes a text emoji' do + expect(subject.includes_unicode_emoji?(text)).to be(includes_emoji) + end + end + end +end diff --git a/spec/lib/gitlab/danger/helper_spec.rb b/spec/lib/gitlab/danger/helper_spec.rb index edcd020a10fa5b390c186155862575827db67544..ae0fcf443c51c7b52cc5066e32f277d22b1722a8 100644 --- a/spec/lib/gitlab/danger/helper_spec.rb +++ b/spec/lib/gitlab/danger/helper_spec.rb @@ -313,6 +313,19 @@ describe Gitlab::Danger::Helper do end end + describe '#sanitize_mr_title' do + where(:mr_title, :expected_mr_title) do + 'My MR title' | 'My MR title' + 'WIP: My MR title' | 'My MR title' + end + + with_them do + subject { helper.sanitize_mr_title(mr_title) } + + it { is_expected.to eq(expected_mr_title) } + end + end + describe '#security_mr?' do it 'returns false when `gitlab_helper` is unavailable' do expect(helper).to receive(:gitlab_helper).and_return(nil) diff --git a/spec/lib/gitlab/data_builder/note_spec.rb b/spec/lib/gitlab/data_builder/note_spec.rb index 3c26daba5a55ac9a6902e20b1e03d2a1193f963c..4b799c23de8762fccf8b17d73b84f2eb67dff8f4 100644 --- a/spec/lib/gitlab/data_builder/note_spec.rb +++ b/spec/lib/gitlab/data_builder/note_spec.rb @@ -137,7 +137,7 @@ describe Gitlab::DataBuilder::Note do it 'returns the note and project snippet data' do expect(data).to have_key(:snippet) expect(data[:snippet].except('updated_at')) - .to eq(snippet.reload.hook_attrs.except('updated_at')) + .to eq(snippet.hook_attrs.except('updated_at')) expect(data[:snippet]['updated_at']) .to be >= snippet.hook_attrs['updated_at'] end diff --git a/spec/lib/gitlab/database/migration_helpers_spec.rb b/spec/lib/gitlab/database/migration_helpers_spec.rb index cac6908f4b43a6d809233cede549de75d3a5a659..e0b4c8ae1f737e1a317ab753cf0ea0d91d52e46b 100644 --- a/spec/lib/gitlab/database/migration_helpers_spec.rb +++ b/spec/lib/gitlab/database/migration_helpers_spec.rb @@ -325,6 +325,67 @@ describe Gitlab::Database::MigrationHelpers do end end end + + describe 'validate option' do + let(:args) { [:projects, :users] } + let(:options) { { column: :user_id, on_delete: nil } } + + context 'when validate is supplied with a falsey value' do + it_behaves_like 'skips validation', validate: false + it_behaves_like 'skips validation', validate: nil + end + + context 'when validate is supplied with a truthy value' do + it_behaves_like 'performs validation', validate: true + it_behaves_like 'performs validation', validate: :whatever + end + + context 'when validate is not supplied' do + it_behaves_like 'performs validation', {} + end + end + end + end + + describe '#validate_foreign_key' do + context 'when name is provided' do + it 'does not infer the foreign key constraint name' do + expect(model).to receive(:foreign_key_exists?).with(:projects, name: :foo).and_return(true) + + aggregate_failures do + expect(model).not_to receive(:concurrent_foreign_key_name) + expect(model).to receive(:disable_statement_timeout).and_call_original + expect(model).to receive(:execute).with(/statement_timeout/) + expect(model).to receive(:execute).ordered.with(/ALTER TABLE projects VALIDATE CONSTRAINT/) + expect(model).to receive(:execute).ordered.with(/RESET ALL/) + end + + model.validate_foreign_key(:projects, :user_id, name: :foo) + end + end + + context 'when name is not provided' do + it 'infers the foreign key constraint name' do + expect(model).to receive(:foreign_key_exists?).with(:projects, name: anything).and_return(true) + + aggregate_failures do + expect(model).to receive(:concurrent_foreign_key_name) + expect(model).to receive(:disable_statement_timeout).and_call_original + expect(model).to receive(:execute).with(/statement_timeout/) + expect(model).to receive(:execute).ordered.with(/ALTER TABLE projects VALIDATE CONSTRAINT/) + expect(model).to receive(:execute).ordered.with(/RESET ALL/) + end + + model.validate_foreign_key(:projects, :user_id) + end + + context 'when the inferred foreign key constraint does not exist' do + it 'raises an error' do + expect(model).to receive(:foreign_key_exists?).and_return(false) + + expect { model.validate_foreign_key(:projects, :user_id) }.to raise_error(/cannot find/) + end + end end end @@ -1414,7 +1475,11 @@ describe Gitlab::Database::MigrationHelpers do describe '#index_exists_by_name?' do it 'returns true if an index exists' do - expect(model.index_exists_by_name?(:projects, 'index_projects_on_path')) + ActiveRecord::Base.connection.execute( + 'CREATE INDEX test_index_for_index_exists ON projects (path);' + ) + + expect(model.index_exists_by_name?(:projects, 'test_index_for_index_exists')) .to be_truthy end diff --git a/spec/lib/gitlab/database/rename_reserved_paths_migration/v1_spec.rb b/spec/lib/gitlab/database/rename_reserved_paths_migration/v1_spec.rb index b09258ae227a6d0e9d33e601bde1791c3143985d..56767c21ab7ed1ccd801cbe4ca1496cf47d69694 100644 --- a/spec/lib/gitlab/database/rename_reserved_paths_migration/v1_spec.rb +++ b/spec/lib/gitlab/database/rename_reserved_paths_migration/v1_spec.rb @@ -6,12 +6,12 @@ shared_examples 'renames child namespaces' do |type| it 'renames namespaces' do rename_namespaces = double expect(described_class::RenameNamespaces) - .to receive(:new).with(['first-path', 'second-path'], subject) + .to receive(:new).with(%w[first-path second-path], subject) .and_return(rename_namespaces) expect(rename_namespaces).to receive(:rename_namespaces) .with(type: :child) - subject.rename_wildcard_paths(['first-path', 'second-path']) + subject.rename_wildcard_paths(%w[first-path second-path]) end end diff --git a/spec/lib/gitlab/database_importers/instance_administrators/create_group_spec.rb b/spec/lib/gitlab/database_importers/instance_administrators/create_group_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..97f4a7eec75cec0768e80972cae38def156e7b8e --- /dev/null +++ b/spec/lib/gitlab/database_importers/instance_administrators/create_group_spec.rb @@ -0,0 +1,164 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::DatabaseImporters::InstanceAdministrators::CreateGroup do + describe '#execute' do + let(:result) { subject.execute } + + context 'without application_settings' do + it 'returns error' do + expect(subject).to receive(:log_error).and_call_original + expect(result).to eq( + status: :error, + message: 'No application_settings found', + last_step: :validate_application_settings + ) + + expect(Group.count).to eq(0) + end + end + + context 'without admin users' do + let(:application_setting) { Gitlab::CurrentSettings.current_application_settings } + + before do + allow(ApplicationSetting).to receive(:current_without_cache) { application_setting } + end + + it 'returns error' do + expect(subject).to receive(:log_error).and_call_original + expect(result).to eq( + status: :error, + message: 'No active admin user found', + last_step: :validate_admins + ) + + expect(Group.count).to eq(0) + end + end + + context 'with application settings and admin users' do + let(:group) { result[:group] } + let(:application_setting) { Gitlab::CurrentSettings.current_application_settings } + + let!(:user) { create(:user, :admin) } + + before do + allow(ApplicationSetting).to receive(:current_without_cache) { application_setting } + end + + it 'returns correct keys' do + expect(result.keys).to contain_exactly( + :status, :group + ) + end + + it "tracks successful install" do + expect(::Gitlab::Tracking).to receive(:event).with( + 'instance_administrators_group', 'group_created' + ) + + result + end + + it 'creates group' do + expect(result[:status]).to eq(:success) + expect(group).to be_persisted + expect(group.name).to eq('GitLab Instance Administrators') + expect(group.path).to start_with('gitlab-instance-administrators') + expect(group.path.split('-').last.length).to eq(8) + expect(group.visibility_level).to eq(described_class::VISIBILITY_LEVEL) + end + + it 'adds all admins as maintainers' do + admin1 = create(:user, :admin) + admin2 = create(:user, :admin) + create(:user) + + expect(result[:status]).to eq(:success) + expect(group.members.collect(&:user)).to contain_exactly(user, admin1, admin2) + expect(group.members.collect(&:access_level)).to contain_exactly( + Gitlab::Access::OWNER, + Gitlab::Access::MAINTAINER, + Gitlab::Access::MAINTAINER + ) + end + + it 'saves the group id' do + expect(result[:status]).to eq(:success) + expect(application_setting.instance_administrators_group_id).to eq(group.id) + end + + it 'returns error when saving group ID fails' do + allow(application_setting).to receive(:save) { false } + + expect(result).to eq( + status: :error, + message: 'Could not save group ID', + last_step: :save_group_id + ) + end + + context 'when group already exists' do + let(:existing_group) { create(:group) } + + before do + admin1 = create(:user, :admin) + admin2 = create(:user, :admin) + + existing_group.add_owner(user) + existing_group.add_users([admin1, admin2], Gitlab::Access::MAINTAINER) + + application_setting.instance_administrators_group_id = existing_group.id + end + + it 'returns success' do + expect(result).to eq( + status: :success, + group: existing_group + ) + + expect(Group.count).to eq(1) + end + end + + context 'when group cannot be created' do + let(:group) { build(:group) } + + before do + group.errors.add(:base, "Test error") + + expect_next_instance_of(::Groups::CreateService) do |group_create_service| + expect(group_create_service).to receive(:execute) + .and_return(group) + end + end + + it 'returns error' do + expect(subject).to receive(:log_error).and_call_original + expect(result).to eq( + status: :error, + message: 'Could not create group', + last_step: :create_group + ) + end + end + + context 'when user cannot be added to group' do + before do + subject.instance_variable_set(:@instance_admins, [user, build(:user, :admin)]) + end + + it 'returns error' do + expect(subject).to receive(:log_error).and_call_original + expect(result).to eq( + status: :error, + message: 'Could not add admins as members', + last_step: :add_group_members + ) + end + end + end + end +end diff --git a/spec/lib/gitlab/database_importers/self_monitoring/project/create_service_spec.rb b/spec/lib/gitlab/database_importers/self_monitoring/project/create_service_spec.rb index ee3c99afdf174495a06410e3da971b8f982bb8d0..10efdd44f20a5364088e91739eeaaac524ed9ad3 100644 --- a/spec/lib/gitlab/database_importers/self_monitoring/project/create_service_spec.rb +++ b/spec/lib/gitlab/database_importers/self_monitoring/project/create_service_spec.rb @@ -4,7 +4,7 @@ require 'spec_helper' describe Gitlab::DatabaseImporters::SelfMonitoring::Project::CreateService do describe '#execute' do - let(:result) { subject.execute! } + let(:result) { subject.execute } let(:prometheus_settings) do { @@ -18,10 +18,12 @@ describe Gitlab::DatabaseImporters::SelfMonitoring::Project::CreateService do end context 'without application_settings' do - it 'does not fail' do + it 'returns error' do expect(subject).to receive(:log_error).and_call_original expect(result).to eq( - status: :success + status: :error, + message: 'No application_settings found', + last_step: :validate_application_settings ) expect(Project.count).to eq(0) @@ -36,10 +38,11 @@ describe Gitlab::DatabaseImporters::SelfMonitoring::Project::CreateService do allow(ApplicationSetting).to receive(:current_without_cache) { application_setting } end - it 'does not fail' do - expect(subject).to receive(:log_error).and_call_original + it 'returns error' do expect(result).to eq( - status: :success + status: :error, + message: 'No active admin user found', + last_step: :create_group ) expect(Project.count).to eq(0) @@ -47,7 +50,7 @@ describe Gitlab::DatabaseImporters::SelfMonitoring::Project::CreateService do end end - context 'with admin users' do + context 'with application settings and admin users' do let(:project) { result[:project] } let(:group) { result[:group] } let(:application_setting) { Gitlab::CurrentSettings.current_application_settings } @@ -73,13 +76,16 @@ describe Gitlab::DatabaseImporters::SelfMonitoring::Project::CreateService do it_behaves_like 'has prometheus service', 'http://localhost:9090' + it "tracks successful install" do + expect(::Gitlab::Tracking).to receive(:event).twice + expect(::Gitlab::Tracking).to receive(:event).with('self_monitoring', 'project_created') + + result + end + it 'creates group' do expect(result[:status]).to eq(:success) expect(group).to be_persisted - expect(group.name).to eq('GitLab Instance Administrators') - expect(group.path).to start_with('gitlab-instance-administrators') - expect(group.path.split('-').last.length).to eq(8) - expect(group.visibility_level).to eq(described_class::VISIBILITY_LEVEL) end it 'creates project with internal visibility' do @@ -109,19 +115,9 @@ describe Gitlab::DatabaseImporters::SelfMonitoring::Project::CreateService do expect(File).to exist("doc/#{path}.md") end - it 'adds all admins as maintainers' do - admin1 = create(:user, :admin) - admin2 = create(:user, :admin) - create(:user) - + it 'creates project with group as owner' do expect(result[:status]).to eq(:success) expect(project.owner).to eq(group) - expect(group.members.collect(&:user)).to contain_exactly(user, admin1, admin2) - expect(group.members.collect(&:access_level)).to contain_exactly( - Gitlab::Access::OWNER, - Gitlab::Access::MAINTAINER, - Gitlab::Access::MAINTAINER - ) end it 'saves the project id' do @@ -130,9 +126,16 @@ describe Gitlab::DatabaseImporters::SelfMonitoring::Project::CreateService do end it 'returns error when saving project ID fails' do - allow(application_setting).to receive(:save) { false } + allow(application_setting).to receive(:update).and_call_original + allow(application_setting).to receive(:update) + .with(instance_administration_project_id: anything) + .and_return(false) - expect { result }.to raise_error(StandardError, 'Could not save project ID') + expect(result).to eq( + status: :error, + message: 'Could not save project ID', + last_step: :save_project_id + ) end context 'when project already exists' do @@ -140,18 +143,12 @@ describe Gitlab::DatabaseImporters::SelfMonitoring::Project::CreateService do let(:existing_project) { create(:project, namespace: existing_group) } before do - admin1 = create(:user, :admin) - admin2 = create(:user, :admin) - - existing_group.add_owner(user) - existing_group.add_users([admin1, admin2], Gitlab::Access::MAINTAINER) - + application_setting.instance_administrators_group_id = existing_group.id application_setting.instance_administration_project_id = existing_project.id end - it 'does not fail' do - expect(subject).to receive(:log_error).and_call_original - expect(result[:status]).to eq(:success) + it 'returns success' do + expect(result).to include(status: :success) expect(Project.count).to eq(1) expect(Group.count).to eq(1) @@ -250,18 +247,11 @@ describe Gitlab::DatabaseImporters::SelfMonitoring::Project::CreateService do it 'returns error' do expect(subject).to receive(:log_error).and_call_original - expect { result }.to raise_error(StandardError, 'Could not create project') - end - end - - context 'when user cannot be added to project' do - before do - subject.instance_variable_set(:@instance_admins, [user, build(:user, :admin)]) - end - - it 'returns error' do - expect(subject).to receive(:log_error).and_call_original - expect { result }.to raise_error(StandardError, 'Could not add admins as members') + expect(result).to eq( + status: :error, + message: 'Could not create project', + last_step: :create_project + ) end end @@ -275,15 +265,13 @@ describe Gitlab::DatabaseImporters::SelfMonitoring::Project::CreateService do it 'returns error' do expect(subject).to receive(:log_error).and_call_original - expect { result }.to raise_error(StandardError, 'Could not save prometheus manual configuration') + expect(result).to eq( + status: :error, + message: 'Could not save prometheus manual configuration', + last_step: :add_prometheus_manual_configuration + ) end end end - - it "tracks successful install" do - expect(Gitlab::Tracking).to receive(:event).with("self_monitoring", "project_created") - - result - end end end diff --git a/spec/lib/gitlab/database_importers/self_monitoring/project/delete_service_spec.rb b/spec/lib/gitlab/database_importers/self_monitoring/project/delete_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..6446ab1beb4bae6bc47ca0d7c7777ae5236b0b1c --- /dev/null +++ b/spec/lib/gitlab/database_importers/self_monitoring/project/delete_service_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::DatabaseImporters::SelfMonitoring::Project::DeleteService do + describe '#execute' do + let!(:application_setting) { create(:application_setting) } + let(:result) { subject.execute } + + context 'when project does not exist' do + it 'returns error' do + expect(result).to eq( + status: :error, + message: 'Self monitoring project does not exist', + last_step: :validate_self_monitoring_project_exists + ) + end + end + + context 'when self monitoring project exists' do + let(:group) { create(:group) } + let(:project) { create(:project, namespace: group) } + + let(:application_setting) do + create( + :application_setting, + instance_administration_project_id: project.id, + instance_administrators_group_id: group.id + ) + end + + it 'destroys project' do + subject.execute + + expect { project.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + + it 'deletes project ID from application settings' do + subject.execute + + expect(application_setting.reload.instance_administration_project_id).to be_nil + end + + it 'does not delete group' do + subject.execute + + expect(application_setting.instance_administrators_group).to eq(group) + end + end + end +end diff --git a/spec/lib/gitlab/database_spec.rb b/spec/lib/gitlab/database_spec.rb index 3db8900ed8e7defc22b7db695a65e3badb2f4997..4a0eab3ea27ed3eeabbdecab93ba782bcaf39e1c 100644 --- a/spec/lib/gitlab/database_spec.rb +++ b/spec/lib/gitlab/database_spec.rb @@ -396,6 +396,20 @@ describe Gitlab::Database do end end + describe '.exists?' do + it 'returns true if `ActiveRecord::Base.connection` succeeds' do + expect(ActiveRecord::Base).to receive(:connection) + + expect(described_class.exists?).to be(true) + end + + it 'returns false if `ActiveRecord::Base.connection` fails' do + expect(ActiveRecord::Base).to receive(:connection) { raise ActiveRecord::NoDatabaseError, 'broken' } + + expect(described_class.exists?).to be(false) + end + end + describe '#true_value' do it 'returns correct value' do expect(described_class.true_value).to eq "'t'" diff --git a/spec/lib/gitlab/dependency_linker/cargo_toml_linker_spec.rb b/spec/lib/gitlab/dependency_linker/cargo_toml_linker_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..86d5bc93bf74eb3f34a361c2e122027705ef3f8a --- /dev/null +++ b/spec/lib/gitlab/dependency_linker/cargo_toml_linker_spec.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::DependencyLinker::CargoTomlLinker do + describe '.support?' do + it 'supports Cargo.toml' do + expect(described_class.support?('Cargo.toml')).to be_truthy + end + + it 'does not support other files' do + expect(described_class.support?('cargo.yaml')).to be_falsey + end + end + + describe '#link' do + let(:file_name) { "Cargo.toml" } + + let(:file_content) do + <<-CONTENT.strip_heredoc + # See https://doc.rust-lang.org/cargo/reference/manifest.html + [package] + # Package shouldn't be matched + name = "gitlab-test" + version = "0.0.1" + authors = ["Some User <some.user@example.org>"] + description = "A GitLab test Cargo.toml." + keywords = ["gitlab", "test", "rust", "crago"] + readme = "README.md" + + [dependencies] + # Default dependencies format with fixed version and version range + chrono = "0.4.7" + xml-rs = ">=0.8.0" + + [dependencies.memchr] + # Specific dependency with optional info + version = "2.2.1" + optional = true + + [dev-dependencies] + # Dev dependency with version modifier + commandspec = "~0.12.2" + + [build-dependencies] + # Build dependency with version wildcard + thread_local = "0.3.*" + CONTENT + end + + subject { Gitlab::Highlight.highlight(file_name, file_content) } + + def link(name, url) + %{<a href="#{url}" rel="nofollow noreferrer noopener" target="_blank">#{name}</a>} + end + + it 'links dependencies' do + expect(subject).to include(link('chrono', 'https://crates.io/crates/chrono')) + expect(subject).to include(link('xml-rs', 'https://crates.io/crates/xml-rs')) + expect(subject).to include(link('memchr', 'https://crates.io/crates/memchr')) + expect(subject).to include(link('commandspec', 'https://crates.io/crates/commandspec')) + expect(subject).to include(link('thread_local', 'https://crates.io/crates/thread_local')) + end + + it 'does not contain metadata identified as package' do + expect(subject).not_to include(link('version', 'https://crates.io/crates/version')) + end + end +end diff --git a/spec/lib/gitlab/dependency_linker_spec.rb b/spec/lib/gitlab/dependency_linker_spec.rb index 3ea3334caf04c1dd217a511fc2a699581dcfed04..570a994f52055e10392b31c4cd0b9918f2228501 100644 --- a/spec/lib/gitlab/dependency_linker_spec.rb +++ b/spec/lib/gitlab/dependency_linker_spec.rb @@ -83,5 +83,13 @@ describe Gitlab::DependencyLinker do described_class.link(blob_name, nil, nil) end + + it 'links using CargoTomlLinker' do + blob_name = 'Cargo.toml' + + expect(described_class::CargoTomlLinker).to receive(:link) + + described_class.link(blob_name, nil, nil) + end end end diff --git a/spec/lib/gitlab/diff/file_spec.rb b/spec/lib/gitlab/diff/file_spec.rb index 716fc8ae9873a7f4c251a2b26bdc830242fa7e5b..c468af4db68133058a289832b85c396e39622c9c 100644 --- a/spec/lib/gitlab/diff/file_spec.rb +++ b/spec/lib/gitlab/diff/file_spec.rb @@ -347,6 +347,16 @@ describe Gitlab::Diff::File do end describe '#simple_viewer' do + context 'when the file is collapsed' do + before do + allow(diff_file).to receive(:collapsed?).and_return(true) + end + + it 'returns a Collapsed viewer' do + expect(diff_file.simple_viewer).to be_a(DiffViewer::Collapsed) + end + end + context 'when the file is not diffable' do before do allow(diff_file).to receive(:diffable?).and_return(false) diff --git a/spec/lib/gitlab/email/attachment_uploader_spec.rb b/spec/lib/gitlab/email/attachment_uploader_spec.rb index d66a746284dae3ad44e89f528e99afbdc820b7e2..c69b2f1eabc477d5aee6d87de2424e1bbb2204fa 100644 --- a/spec/lib/gitlab/email/attachment_uploader_spec.rb +++ b/spec/lib/gitlab/email/attachment_uploader_spec.rb @@ -9,7 +9,7 @@ describe Gitlab::Email::AttachmentUploader do let(:message) { Mail::Message.new(message_raw) } it "uploads all attachments and returns their links" do - links = described_class.new(message).execute(project) + links = described_class.new(message).execute(upload_parent: project, uploader_class: FileUploader) link = links.first expect(link).not_to be_nil diff --git a/spec/lib/gitlab/email/handler/create_note_handler_spec.rb b/spec/lib/gitlab/email/handler/create_note_handler_spec.rb index 50e473c459e10ca3d1cdabae43eb06817288b00c..909a7618df42d3662525906fc3e65f80d3c30bc7 100644 --- a/spec/lib/gitlab/email/handler/create_note_handler_spec.rb +++ b/spec/lib/gitlab/email/handler/create_note_handler_spec.rb @@ -181,10 +181,21 @@ describe Gitlab::Email::Handler::CreateNoteHandler do it_behaves_like 'a reply to existing comment' it "adds all attachments" do + expect_next_instance_of(Gitlab::Email::AttachmentUploader) do |uploader| + expect(uploader).to receive(:execute).with(upload_parent: project, uploader_class: FileUploader).and_return( + [ + { + url: "uploads/image.png", + alt: "image", + markdown: markdown + } + ] + ) + end + receiver.execute note = noteable.notes.last - expect(note.note).to include(markdown) end diff --git a/spec/lib/gitlab/email/receiver_spec.rb b/spec/lib/gitlab/email/receiver_spec.rb index 43c73242f5ff520f5e2d3dc39f5cf9ab7a3e67f4..018219e56478aacc268bb3166ccc8bcb3cec2364 100644 --- a/spec/lib/gitlab/email/receiver_spec.rb +++ b/spec/lib/gitlab/email/receiver_spec.rb @@ -5,22 +5,27 @@ require 'spec_helper' describe Gitlab::Email::Receiver do include_context :email_shared_context - context "when the email contains a valid email address in a Delivered-To header" do - let(:email_raw) { fixture_file('emails/forwarded_new_issue.eml') } + context 'when the email contains a valid email address in a header' do let(:handler) { double(:handler) } before do - stub_incoming_email_setting(enabled: true, address: "incoming+%{key}@appmail.adventuretime.ooo") - allow(handler).to receive(:execute) allow(handler).to receive(:metrics_params) allow(handler).to receive(:metrics_event) + + stub_incoming_email_setting(enabled: true, address: "incoming+%{key}@appmail.example.com") + end + + context 'when in a Delivered-To header' do + let(:email_raw) { fixture_file('emails/forwarded_new_issue.eml') } + + it_behaves_like 'correctly finds the mail key' end - it "finds the mail key" do - expect(Gitlab::Email::Handler).to receive(:for).with(an_instance_of(Mail::Message), 'gitlabhq/gitlabhq+auth_token').and_return(handler) + context 'when in an Envelope-To header' do + let(:email_raw) { fixture_file('emails/envelope_to_header.eml') } - receiver.execute + it_behaves_like 'correctly finds the mail key' end end diff --git a/spec/lib/gitlab/experimentation_spec.rb b/spec/lib/gitlab/experimentation_spec.rb index b8be72cf8d72bc13f3a16c4d853ccdc8a930e232..e4624accd58d66906aac960c528121bc978d6e3a 100644 --- a/spec/lib/gitlab/experimentation_spec.rb +++ b/spec/lib/gitlab/experimentation_spec.rb @@ -54,7 +54,7 @@ describe Gitlab::Experimentation do describe '#experiment_enabled?' do context 'cookie is not present' do it 'calls Gitlab::Experimentation.enabled_for_user? with the name of the experiment and an experimentation_subject_index of nil' do - expect(Gitlab::Experimentation).to receive(:enabled_for_user?).with(:test_experiment, nil) # rubocop:disable RSpec/DescribedClass + expect(Gitlab::Experimentation).to receive(:enabled_for_user?).with(:test_experiment, nil) controller.experiment_enabled?(:test_experiment) end end @@ -67,7 +67,7 @@ describe Gitlab::Experimentation do it 'calls Gitlab::Experimentation.enabled_for_user? with the name of the experiment and an experimentation_subject_index of the modulo 100 of the hex value of the uuid' do # 'abcd1234'.hex % 100 = 76 - expect(Gitlab::Experimentation).to receive(:enabled_for_user?).with(:test_experiment, 76) # rubocop:disable RSpec/DescribedClass + expect(Gitlab::Experimentation).to receive(:enabled_for_user?).with(:test_experiment, 76) controller.experiment_enabled?(:test_experiment) end end diff --git a/spec/lib/gitlab/file_detector_spec.rb b/spec/lib/gitlab/file_detector_spec.rb index 23f7deba7f70935054fc9a7dddc73ac20e74675b..3972bd24e80a40d478100a512ebceff480f0e63f 100644 --- a/spec/lib/gitlab/file_detector_spec.rb +++ b/spec/lib/gitlab/file_detector_spec.rb @@ -16,23 +16,30 @@ describe Gitlab::FileDetector do end describe '.type_of' do - it 'returns the type of a README file' do - filenames = Gitlab::MarkupHelper::PLAIN_FILENAMES + Gitlab::MarkupHelper::PLAIN_FILENAMES.map(&:upcase) - extensions = Gitlab::MarkupHelper::EXTENSIONS + Gitlab::MarkupHelper::EXTENSIONS.map(&:upcase) + it 'returns the type of a README without extension' do + expect(described_class.type_of('README')).to eq(:readme) + expect(described_class.type_of('INDEX')).to eq(:readme) + end - filenames.each do |filename| - expect(described_class.type_of(filename)).to eq(:readme) + it 'returns the type of a README file with a recognized extension' do + extensions = ['txt', *Gitlab::MarkupHelper::EXTENSIONS] - extensions.each do |extname| - expect(described_class.type_of("#{filename}.#{extname}")).to eq(:readme) + extensions.each do |ext| + %w(index readme).each do |file| + expect(described_class.type_of("#{file}.#{ext}")).to eq(:readme) end end end - it 'returns nil for a README.rb file' do + it 'returns nil for a README with unrecognized extension' do expect(described_class.type_of('README.rb')).to be_nil end + it 'is case insensitive' do + expect(described_class.type_of('ReadMe')).to eq(:readme) + expect(described_class.type_of('index.TXT')).to eq(:readme) + end + it 'returns nil for a README file in a directory' do expect(described_class.type_of('foo/README.md')).to be_nil end diff --git a/spec/lib/gitlab/file_finder_spec.rb b/spec/lib/gitlab/file_finder_spec.rb index 6cc5141a6fe49ff1ee8061cd1a3c93468f29f72c..90aa759671a2726064b5facefbc04e59529e3825 100644 --- a/spec/lib/gitlab/file_finder_spec.rb +++ b/spec/lib/gitlab/file_finder_spec.rb @@ -30,5 +30,11 @@ describe Gitlab::FileFinder do expect(results.count).to eq(1) end + + it 'does not cause N+1 query' do + expect(Gitlab::GitalyClient).to receive(:call).at_most(10).times.and_call_original + + subject.find(': filename:wm.svg') + end end end diff --git a/spec/lib/gitlab/plugin_spec.rb b/spec/lib/gitlab/file_hook_spec.rb similarity index 67% rename from spec/lib/gitlab/plugin_spec.rb rename to spec/lib/gitlab/file_hook_spec.rb index 5d9f6d04caad7c0a7bc3d3a11329000d858b2c11..d184eb483d4edaa05f64223d3c6ae459901469ee 100644 --- a/spec/lib/gitlab/plugin_spec.rb +++ b/spec/lib/gitlab/file_hook_spec.rb @@ -2,11 +2,11 @@ require 'spec_helper' -describe Gitlab::Plugin do - let(:plugin) { Rails.root.join('plugins', 'test.rb') } - let(:tmp_file) { Tempfile.new('plugin-dump') } +describe Gitlab::FileHook do + let(:file_hook) { Rails.root.join('plugins', 'test.rb') } + let(:tmp_file) { Tempfile.new('file_hook-dump') } - let(:plugin_source) do + let(:file_hook_source) do <<~EOS #!/usr/bin/env ruby x = STDIN.read @@ -14,13 +14,13 @@ describe Gitlab::Plugin do EOS end - context 'with plugins present' do + context 'with file_hooks present' do before do - File.write(plugin, plugin_source) + File.write(file_hook, file_hook_source) end after do - FileUtils.rm(plugin) + FileUtils.rm(file_hook) end describe '.any?' do @@ -30,13 +30,13 @@ describe Gitlab::Plugin do end describe '.files?' do - it 'returns a list of plugins' do - expect(described_class.files).to match_array([plugin.to_s]) + it 'returns a list of file_hooks' do + expect(described_class.files).to match_array([file_hook.to_s]) end end end - context 'without any plugins' do + context 'without any file_hooks' do describe '.any?' do it 'returns false' do expect(described_class.any?).to be false @@ -52,21 +52,21 @@ describe Gitlab::Plugin do describe '.execute' do let(:data) { Gitlab::DataBuilder::Push::SAMPLE_DATA } - let(:result) { described_class.execute(plugin.to_s, data) } + let(:result) { described_class.execute(file_hook.to_s, data) } let(:success) { result.first } let(:message) { result.last } before do - File.write(plugin, plugin_source) + File.write(file_hook, file_hook_source) end after do - FileUtils.rm(plugin) + FileUtils.rm(file_hook) end context 'successful execution' do before do - File.chmod(0o777, plugin) + File.chmod(0o777, file_hook) end after do @@ -76,7 +76,7 @@ describe Gitlab::Plugin do it { expect(success).to be true } it { expect(message).to be_empty } - it 'ensures plugin received data via stdin' do + it 'ensures file_hook received data via stdin' do result expect(File.read(tmp_file.path)).to eq(data.to_json) @@ -89,7 +89,7 @@ describe Gitlab::Plugin do end context 'non-zero exit' do - let(:plugin_source) do + let(:file_hook_source) do <<~EOS #!/usr/bin/env ruby exit 1 @@ -97,7 +97,7 @@ describe Gitlab::Plugin do end before do - File.chmod(0o777, plugin) + File.chmod(0o777, file_hook) end it { expect(success).to be false } diff --git a/spec/lib/gitlab/git/branch_spec.rb b/spec/lib/gitlab/git/branch_spec.rb index cc26b7e7fcdd13b778fbc2bffcc39b5dcb107753..cb3f4df2dbd165a43b11f117121e2f8f38480a82 100644 --- a/spec/lib/gitlab/git/branch_spec.rb +++ b/spec/lib/gitlab/git/branch_spec.rb @@ -71,9 +71,7 @@ describe Gitlab::Git::Branch, :seed_helper do end let(:user) { create(:user) } - let(:committer) do - Gitlab::Git.committer_hash(email: user.email, name: user.name) - end + let(:committer) { { email: user.email, name: user.name } } let(:params) do parents = [rugged.head.target] tree = parents.first.tree diff --git a/spec/lib/gitlab/git/commit_spec.rb b/spec/lib/gitlab/git/commit_spec.rb index 7ec655eb113f45064bf48dfa7e4582f1e568c06d..c2fc228d34aea450b4186598f1260ebc1dfece50 100644 --- a/spec/lib/gitlab/git/commit_spec.rb +++ b/spec/lib/gitlab/git/commit_spec.rb @@ -57,7 +57,7 @@ describe Gitlab::Git::Commit, :seed_helper do it { expect(@commit.different_committer?).to be_truthy } it { expect(@commit.parents).to eq(@gitlab_parents) } it { expect(@commit.parent_id).to eq(@parents.first.oid) } - it { expect(@commit.no_commit_message).to eq("--no commit message") } + it { expect(@commit.no_commit_message).to eq("No commit message") } after do # Erase the new commit so other tests get the original repo diff --git a/spec/lib/gitlab/git/rugged_impl/use_rugged_spec.rb b/spec/lib/gitlab/git/rugged_impl/use_rugged_spec.rb index 474240cf620d6346803423990bc04c1db8e8cd2a..9b29046fce9f706298dd3c8b3dc370be4ea2de54 100644 --- a/spec/lib/gitlab/git/rugged_impl/use_rugged_spec.rb +++ b/spec/lib/gitlab/git/rugged_impl/use_rugged_spec.rb @@ -53,30 +53,46 @@ describe Gitlab::Git::RuggedImpl::UseRugged, :seed_helper do allow(Feature).to receive(:persisted?).with(feature_flag).and_return(false) end - it 'returns true when gitaly matches disk' do - expect(subject.use_rugged?(repository, feature_flag_name)).to be true + context 'when running puma with multiple threads' do + before do + allow(subject).to receive(:running_puma_with_multiple_threads?).and_return(true) + end + + it 'returns false' do + expect(subject.use_rugged?(repository, feature_flag_name)).to be false + end end - it 'returns false when disk access fails' do - allow(Gitlab::GitalyClient).to receive(:storage_metadata_file_path).and_return("/fake/path/doesnt/exist") + context 'when not running puma with multiple threads' do + before do + allow(subject).to receive(:running_puma_with_multiple_threads?).and_return(false) + end - expect(subject.use_rugged?(repository, feature_flag_name)).to be false - end + it 'returns true when gitaly matches disk' do + expect(subject.use_rugged?(repository, feature_flag_name)).to be true + end - it "returns false when gitaly doesn't match disk" do - allow(Gitlab::GitalyClient).to receive(:storage_metadata_file_path).and_return(temp_gitaly_metadata_file) + it 'returns false when disk access fails' do + allow(Gitlab::GitalyClient).to receive(:storage_metadata_file_path).and_return("/fake/path/doesnt/exist") - expect(subject.use_rugged?(repository, feature_flag_name)).to be_falsey + expect(subject.use_rugged?(repository, feature_flag_name)).to be false + end - File.delete(temp_gitaly_metadata_file) - end + it "returns false when gitaly doesn't match disk" do + allow(Gitlab::GitalyClient).to receive(:storage_metadata_file_path).and_return(temp_gitaly_metadata_file) - it "doesn't lead to a second rpc call because gitaly client should use the cached value" do - expect(subject.use_rugged?(repository, feature_flag_name)).to be true + expect(subject.use_rugged?(repository, feature_flag_name)).to be_falsey - expect(Gitlab::GitalyClient).not_to receive(:filesystem_id) + File.delete(temp_gitaly_metadata_file) + end - subject.use_rugged?(repository, feature_flag_name) + it "doesn't lead to a second rpc call because gitaly client should use the cached value" do + expect(subject.use_rugged?(repository, feature_flag_name)).to be true + + expect(Gitlab::GitalyClient).not_to receive(:filesystem_id) + + subject.use_rugged?(repository, feature_flag_name) + end end end @@ -99,6 +115,37 @@ describe Gitlab::Git::RuggedImpl::UseRugged, :seed_helper do end end + describe '#running_puma_with_multiple_threads?' do + context 'when using Puma' do + before do + stub_const('::Puma', class_double('Puma')) + allow(Gitlab::Runtime).to receive(:puma?).and_return(true) + end + + it 'returns false for single thread Puma' do + allow(::Puma).to receive_message_chain(:cli_config, :options).and_return(max_threads: 1) + + expect(subject.running_puma_with_multiple_threads?).to be false + end + + it 'returns true for multi-threaded Puma' do + allow(::Puma).to receive_message_chain(:cli_config, :options).and_return(max_threads: 2) + + expect(subject.running_puma_with_multiple_threads?).to be true + end + end + + context 'when not using Puma' do + before do + allow(Gitlab::Runtime).to receive(:puma?).and_return(false) + end + + it 'returns false' do + expect(subject.running_puma_with_multiple_threads?).to be false + end + end + end + def create_temporary_gitaly_metadata_file tmp = Tempfile.new('.gitaly-metadata') gitaly_metadata = { diff --git a/spec/lib/gitlab/git_spec.rb b/spec/lib/gitlab/git_spec.rb index fbc49e05c37e474078817bd796c3d313517a893c..d6d12b84724afad7856e2952873ca7c9d313990f 100644 --- a/spec/lib/gitlab/git_spec.rb +++ b/spec/lib/gitlab/git_spec.rb @@ -6,32 +6,6 @@ describe Gitlab::Git do let(:committer_email) { 'user@example.org' } let(:committer_name) { 'John Doe' } - describe 'committer_hash' do - it "returns a hash containing the given email and name" do - committer_hash = described_class.committer_hash(email: committer_email, name: committer_name) - - expect(committer_hash[:email]).to eq(committer_email) - expect(committer_hash[:name]).to eq(committer_name) - expect(committer_hash[:time]).to be_a(Time) - end - - context 'when email is nil' do - it "returns nil" do - committer_hash = described_class.committer_hash(email: nil, name: committer_name) - - expect(committer_hash).to be_nil - end - end - - context 'when name is nil' do - it "returns nil" do - committer_hash = described_class.committer_hash(email: committer_email, name: nil) - - expect(committer_hash).to be_nil - end - end - end - describe '.ref_name' do it 'ensure ref is a valid UTF-8 string' do utf8_invalid_ref = Gitlab::Git::BRANCH_REF_PREFIX + "an_invalid_ref_\xE5" @@ -73,7 +47,8 @@ describe Gitlab::Git do [sha, short_sha, true], [sha, sha.reverse, false], [sha, too_short_sha, false], - [sha, nil, false] + [sha, nil, false], + [nil, nil, true] ] end diff --git a/spec/lib/gitlab/gitaly_client/blob_service_spec.rb b/spec/lib/gitlab/gitaly_client/blob_service_spec.rb index 887a6baf6594c4eba34343103327c5c80b84c4cb..fc6ac4916710fdc6721990081980344f1dd5bccd 100644 --- a/spec/lib/gitlab/gitaly_client/blob_service_spec.rb +++ b/spec/lib/gitlab/gitaly_client/blob_service_spec.rb @@ -12,7 +12,7 @@ describe Gitlab::GitalyClient::BlobService do describe '#get_new_lfs_pointers' do let(:revision) { 'master' } let(:limit) { 5 } - let(:not_in) { ['branch-a', 'branch-b'] } + let(:not_in) { %w[branch-a branch-b] } let(:expected_params) do { revision: revision, limit: limit, not_in_refs: not_in, not_in_all: false } end diff --git a/spec/lib/gitlab/gitaly_client/remote_service_spec.rb b/spec/lib/gitlab/gitaly_client/remote_service_spec.rb index 929ff5dee5d4747dc22aeffc7c9d8d65afd93e4c..73ae4cd95ce5f72b77ba1dc755946d9b64f57023 100644 --- a/spec/lib/gitlab/gitaly_client/remote_service_spec.rb +++ b/spec/lib/gitlab/gitaly_client/remote_service_spec.rb @@ -69,7 +69,7 @@ describe Gitlab::GitalyClient::RemoteService do describe '#update_remote_mirror' do let(:ref_name) { 'remote_mirror_1' } - let(:only_branches_matching) { ['my-branch', 'master'] } + let(:only_branches_matching) { %w[my-branch master] } let(:ssh_key) { 'KEY' } let(:known_hosts) { 'KNOWN HOSTS' } diff --git a/spec/lib/gitlab/gitaly_client_spec.rb b/spec/lib/gitlab/gitaly_client_spec.rb index 4b69b4734f147b2c2754c1bc460178aafc4f6c69..ebf56c0ae668370355efa8a5cd8716192e60f25a 100644 --- a/spec/lib/gitlab/gitaly_client_spec.rb +++ b/spec/lib/gitlab/gitaly_client_spec.rb @@ -26,7 +26,7 @@ describe Gitlab::GitalyClient do context 'running in Unicorn' do before do - stub_const('Unicorn', 1) + allow(Gitlab::Runtime).to receive(:unicorn?).and_return(true) end it { expect(subject.long_timeout).to eq(55) } @@ -34,7 +34,7 @@ describe Gitlab::GitalyClient do context 'running in Puma' do before do - stub_const('Puma', 1) + allow(Gitlab::Runtime).to receive(:puma?).and_return(true) end it { expect(subject.long_timeout).to eq(55) } @@ -229,6 +229,59 @@ describe Gitlab::GitalyClient do end end end + + context 'deadlines', :request_store do + let(:request_deadline) { real_time + 10.0 } + + before do + allow(Gitlab::RequestContext.instance).to receive(:request_deadline).and_return(request_deadline) + end + + it 'includes the deadline information' do + kword_args = described_class.request_kwargs('default', timeout: 2) + + expect(kword_args[:deadline]) + .to be_within(1).of(real_time + 2) + expect(kword_args[:metadata][:deadline_type]).to eq("regular") + end + + it 'limits the deadline do the request deadline if that is closer', :aggregate_failures do + kword_args = described_class.request_kwargs('default', timeout: 15) + + expect(kword_args[:deadline]).to eq(request_deadline) + expect(kword_args[:metadata][:deadline_type]).to eq("limited") + end + + it 'does not limit calls in sidekiq' do + expect(Sidekiq).to receive(:server?).and_return(true) + + kword_args = described_class.request_kwargs('default', timeout: 6.hours.to_i) + + expect(kword_args[:deadline]).to be_within(1).of(real_time + 6.hours.to_i) + expect(kword_args[:metadata][:deadline_type]).to be_nil + end + + it 'does not limit calls in sidekiq when allowed unlimited' do + expect(Sidekiq).to receive(:server?).and_return(true) + + kword_args = described_class.request_kwargs('default', timeout: 0) + + expect(kword_args[:deadline]).to be_nil + expect(kword_args[:metadata][:deadline_type]).to be_nil + end + + it 'includes only the deadline specified by the timeout when there was no deadline' do + allow(Gitlab::RequestContext.instance).to receive(:request_deadline).and_return(nil) + kword_args = described_class.request_kwargs('default', timeout: 6.hours.to_i) + + expect(kword_args[:deadline]).to be_within(1).of(Gitlab::Metrics::System.real_time + 6.hours.to_i) + expect(kword_args[:metadata][:deadline_type]).to be_nil + end + + def real_time + Gitlab::Metrics::System.real_time + end + end end describe 'enforce_gitaly_request_limits?' do diff --git a/spec/lib/gitlab/github_import/importer/pull_request_importer_spec.rb b/spec/lib/gitlab/github_import/importer/pull_request_importer_spec.rb index 877b4d4bbafbaffcebf7d9dde986a92e141c635f..bffae9e2ba0e0ef43aca2de4b370910ebdc202b5 100644 --- a/spec/lib/gitlab/github_import/importer/pull_request_importer_spec.rb +++ b/spec/lib/gitlab/github_import/importer/pull_request_importer_spec.rb @@ -49,6 +49,10 @@ describe Gitlab::GithubImport::Importer::PullRequestImporter, :clean_gitlab_redi .to receive(:create_merge_request) .and_return([mr, false]) + expect(importer) + .to receive(:set_merge_request_assignees) + .with(mr) + expect(importer) .to receive(:insert_git_data) .with(mr, false) @@ -75,11 +79,6 @@ describe Gitlab::GithubImport::Importer::PullRequestImporter, :clean_gitlab_redi .to receive(:author_id_for) .with(pull_request) .and_return([user.id, true]) - - allow(importer.user_finder) - .to receive(:assignee_id_for) - .with(pull_request) - .and_return(user.id) end it 'imports the pull request with the pull request author as the merge request author' do @@ -97,7 +96,6 @@ describe Gitlab::GithubImport::Importer::PullRequestImporter, :clean_gitlab_redi state_id: 3, milestone_id: milestone.id, author_id: user.id, - assignee_id: user.id, created_at: created_at, updated_at: updated_at }, @@ -114,20 +112,72 @@ describe Gitlab::GithubImport::Importer::PullRequestImporter, :clean_gitlab_redi expect(mr).to be_instance_of(MergeRequest) expect(exists).to eq(false) end + + context 'when the source and target branch are identical' do + before do + allow(pull_request).to receive_messages( + source_repository_id: pull_request.target_repository_id, + source_branch: 'master' + ) + end + + it 'uses a generated source branch name for the merge request' do + expect(importer) + .to receive(:insert_and_return_id) + .with( + { + iid: 42, + title: 'My Pull Request', + description: 'This is my pull request', + source_project_id: project.id, + target_project_id: project.id, + source_branch: 'master-42', + target_branch: 'master', + state_id: 3, + milestone_id: milestone.id, + author_id: user.id, + created_at: created_at, + updated_at: updated_at + }, + project.merge_requests + ) + .and_call_original + + importer.create_merge_request + end + end + + context 'when the import fails due to a foreign key error' do + it 'does not raise any errors' do + expect(importer) + .to receive(:insert_and_return_id) + .and_raise(ActiveRecord::InvalidForeignKey, 'invalid foreign key') + + expect { importer.create_merge_request }.not_to raise_error + end + end + + context 'when the merge request already exists' do + it 'returns the existing merge request' do + mr1, exists1 = importer.create_merge_request + mr2, exists2 = importer.create_merge_request + + expect(mr2).to eq(mr1) + expect(exists1).to eq(false) + expect(exists2).to eq(true) + end + end end context 'when the author could not be found' do - it 'imports the pull request with the project creator as the merge request author' do + before do allow(importer.user_finder) .to receive(:author_id_for) .with(pull_request) .and_return([project.creator_id, false]) + end - allow(importer.user_finder) - .to receive(:assignee_id_for) - .with(pull_request) - .and_return(user.id) - + it 'imports the pull request with the project creator as the merge request author' do expect(importer) .to receive(:insert_and_return_id) .with( @@ -142,7 +192,6 @@ describe Gitlab::GithubImport::Importer::PullRequestImporter, :clean_gitlab_redi state_id: 3, milestone_id: milestone.id, author_id: project.creator_id, - assignee_id: user.id, created_at: created_at, updated_at: updated_at }, @@ -153,93 +202,33 @@ describe Gitlab::GithubImport::Importer::PullRequestImporter, :clean_gitlab_redi importer.create_merge_request end end + end - context 'when the source and target branch are identical' do - it 'uses a generated source branch name for the merge request' do - allow(importer.user_finder) - .to receive(:author_id_for) - .with(pull_request) - .and_return([user.id, true]) - - allow(importer.user_finder) - .to receive(:assignee_id_for) - .with(pull_request) - .and_return(user.id) - - allow(pull_request) - .to receive(:source_repository_id) - .and_return(pull_request.target_repository_id) - - allow(pull_request) - .to receive(:source_branch) - .and_return('master') + describe '#set_merge_request_assignees' do + let_it_be(:merge_request) { create(:merge_request) } - expect(importer) - .to receive(:insert_and_return_id) - .with( - { - iid: 42, - title: 'My Pull Request', - description: 'This is my pull request', - source_project_id: project.id, - target_project_id: project.id, - source_branch: 'master-42', - target_branch: 'master', - state_id: 3, - milestone_id: milestone.id, - author_id: user.id, - assignee_id: user.id, - created_at: created_at, - updated_at: updated_at - }, - project.merge_requests - ) - .and_call_original + before do + allow(importer.user_finder) + .to receive(:assignee_id_for) + .with(pull_request) + .and_return(user_id) - importer.create_merge_request - end + importer.set_merge_request_assignees(merge_request) end - context 'when the import fails due to a foreign key error' do - it 'does not raise any errors' do - allow(importer.user_finder) - .to receive(:author_id_for) - .with(pull_request) - .and_return([user.id, true]) - - allow(importer.user_finder) - .to receive(:assignee_id_for) - .with(pull_request) - .and_return(user.id) - - expect(importer) - .to receive(:insert_and_return_id) - .and_raise(ActiveRecord::InvalidForeignKey, 'invalid foreign key') + context 'when pull request has an assignee' do + let(:user_id) { user.id } - expect { importer.create_merge_request }.not_to raise_error + it 'sets merge request assignees' do + expect(merge_request.assignee_ids).to eq [user.id] end end - context 'when the merge request already exists' do - before do - allow(importer.user_finder) - .to receive(:author_id_for) - .with(pull_request) - .and_return([user.id, true]) - - allow(importer.user_finder) - .to receive(:assignee_id_for) - .with(pull_request) - .and_return(user.id) - end - - it 'returns the existing merge request' do - mr1, exists1 = importer.create_merge_request - mr2, exists2 = importer.create_merge_request + context 'when pull request does not have any assignees' do + let(:user_id) { nil } - expect(mr2).to eq(mr1) - expect(exists1).to eq(false) - expect(exists2).to eq(true) + it 'does not set merge request assignees' do + expect(merge_request.assignee_ids).to eq [] end end end @@ -255,11 +244,6 @@ describe Gitlab::GithubImport::Importer::PullRequestImporter, :clean_gitlab_redi .to receive(:author_id_for) .with(pull_request) .and_return([user.id, true]) - - allow(importer.user_finder) - .to receive(:assignee_id_for) - .with(pull_request) - .and_return(user.id) end it 'does not create the source branch if merge request is merged' do diff --git a/spec/lib/gitlab/gpg_spec.rb b/spec/lib/gitlab/gpg_spec.rb index 8600ef223c652b22babd4782835e3347f7d71190..27a3010eeed0beff36ee0bee3d1f389e90aef31e 100644 --- a/spec/lib/gitlab/gpg_spec.rb +++ b/spec/lib/gitlab/gpg_spec.rb @@ -236,7 +236,7 @@ describe Gitlab::Gpg do context 'when running in Sidekiq' do before do - allow(Sidekiq).to receive(:server?).and_return(true) + allow(Gitlab::Runtime).to receive(:sidekiq?).and_return(true) end it_behaves_like 'multiple deletion attempts of the tmp-dir', described_class::BG_CLEANUP_RUNTIME_S diff --git a/spec/lib/gitlab/grape_logging/loggers/exception_logger_spec.rb b/spec/lib/gitlab/grape_logging/loggers/exception_logger_spec.rb index 0cfda80b854295b8ef9a3412b1a00bbcecdc4d1b..c9021e2f43686ecc7c4e28bb6668df75cfe05541 100644 --- a/spec/lib/gitlab/grape_logging/loggers/exception_logger_spec.rb +++ b/spec/lib/gitlab/grape_logging/loggers/exception_logger_spec.rb @@ -39,7 +39,7 @@ describe Gitlab::GrapeLogging::Loggers::ExceptionLogger do before do current_backtrace = caller allow(exception).to receive(:backtrace).and_return(current_backtrace) - expected['exception.backtrace'] = Gitlab::Profiler.clean_backtrace(current_backtrace) + expected['exception.backtrace'] = Gitlab::BacktraceCleaner.clean_backtrace(current_backtrace) end it 'includes the backtrace' do diff --git a/spec/lib/gitlab/graphql/connections/externally_paginated_array_connection_spec.rb b/spec/lib/gitlab/graphql/connections/externally_paginated_array_connection_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..83c94ed62609fb6ccd487846bedbacd8e9ca21fd --- /dev/null +++ b/spec/lib/gitlab/graphql/connections/externally_paginated_array_connection_spec.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::Graphql::Connections::ExternallyPaginatedArrayConnection do + let(:prev_cursor) { 1 } + let(:next_cursor) { 6 } + let(:values) { [2, 3, 4, 5] } + let(:all_nodes) { Gitlab::Graphql::ExternallyPaginatedArray.new(prev_cursor, next_cursor, *values) } + let(:arguments) { {} } + + subject(:connection) do + described_class.new(all_nodes, arguments) + end + + describe '#sliced_nodes' do + let(:sliced_nodes) { connection.sliced_nodes } + + it 'returns all the nodes' do + expect(connection.sliced_nodes).to eq(values) + end + end + + describe '#paged_nodes' do + let(:paged_nodes) { connection.send(:paged_nodes) } + + it_behaves_like "connection with paged nodes" do + let(:paged_nodes_size) { values.size } + end + end + + describe '#start_cursor' do + it 'returns the prev cursor' do + expect(connection.start_cursor).to eq(prev_cursor) + end + + context 'when there is none' do + let(:prev_cursor) { nil } + + it 'returns nil' do + expect(connection.start_cursor).to eq(nil) + end + end + end + + describe '#end_cursor' do + it 'returns the next cursor' do + expect(connection.end_cursor).to eq(next_cursor) + end + + context 'when there is none' do + let(:next_cursor) { nil } + + it 'returns nil' do + expect(connection.end_cursor).to eq(nil) + end + end + end + + describe '#has_next_page' do + it 'returns true when there is a end cursor' do + expect(connection.has_next_page).to eq(true) + end + + context 'there is no end cursor' do + let(:next_cursor) { nil } + + it 'returns false' do + expect(connection.has_next_page).to eq(false) + end + end + end + + describe '#has_previous_page' do + it 'returns true when there is a start cursor' do + expect(connection.has_previous_page).to eq(true) + end + + context 'there is no start cursor' do + let(:prev_cursor) { nil } + + it 'returns false' do + expect(connection.has_previous_page).to eq(false) + end + end + end +end diff --git a/spec/lib/gitlab/graphql/connections/filterable_array_connection_spec.rb b/spec/lib/gitlab/graphql/connections/filterable_array_connection_spec.rb index 20e87daa0d6e180a40cbcb599c4b9aa9fbdab42b..b2f0862be627ba62fac2a77e6c2409754b946001 100644 --- a/spec/lib/gitlab/graphql/connections/filterable_array_connection_spec.rb +++ b/spec/lib/gitlab/graphql/connections/filterable_array_connection_spec.rb @@ -14,7 +14,9 @@ describe Gitlab::Graphql::Connections::FilterableArrayConnection do describe '#paged_nodes' do let(:paged_nodes) { subject.paged_nodes } - it_behaves_like "connection with paged nodes" + it_behaves_like "connection with paged nodes" do + let(:paged_nodes_size) { 3 } + end context 'when callback filters some nodes' do let(:callback) { proc { |nodes| nodes[1..-1] } } diff --git a/spec/lib/gitlab/graphql/connections/keyset/connection_spec.rb b/spec/lib/gitlab/graphql/connections/keyset/connection_spec.rb index bd0fcbbdeb206d1fe342eca562d7a4dfe8cd71f6..f617e8b3ce731a9e10f58124163754a42c37253b 100644 --- a/spec/lib/gitlab/graphql/connections/keyset/connection_spec.rb +++ b/spec/lib/gitlab/graphql/connections/keyset/connection_spec.rb @@ -232,7 +232,9 @@ describe Gitlab::Graphql::Connections::Keyset::Connection do let_it_be(:all_nodes) { create_list(:project, 5) } let(:paged_nodes) { subject.paged_nodes } - it_behaves_like "connection with paged nodes" + it_behaves_like "connection with paged nodes" do + let(:paged_nodes_size) { 3 } + end context 'when both are passed' do let(:arguments) { { first: 2, last: 2 } } diff --git a/spec/lib/gitlab/group_search_results_spec.rb b/spec/lib/gitlab/group_search_results_spec.rb index 570b0cb74010670f3a545186395756fe3999bf2a..746f505c877e28e7f65c73e86775d07bc6f15cce 100644 --- a/spec/lib/gitlab/group_search_results_spec.rb +++ b/spec/lib/gitlab/group_search_results_spec.rb @@ -67,5 +67,11 @@ describe Gitlab::GroupSearchResults do expect(result).to eq [] end + + it 'sets include_subgroups flag by default' do + result = described_class.new(user, anything, group, 'gob') + + expect(result.issuable_params[:include_subgroups]).to eq(true) + end end end diff --git a/spec/lib/gitlab/health_checks/puma_check_spec.rb b/spec/lib/gitlab/health_checks/puma_check_spec.rb index dd052a4dd2ccfc3828d2584b641d2aa47bd85b42..93ef81978a87897c2a014b20a6e810062ef566c5 100644 --- a/spec/lib/gitlab/health_checks/puma_check_spec.rb +++ b/spec/lib/gitlab/health_checks/puma_check_spec.rb @@ -22,6 +22,7 @@ describe Gitlab::HealthChecks::PumaCheck do context 'when Puma is not loaded' do before do + allow(Gitlab::Runtime).to receive(:puma?).and_return(false) hide_const('Puma') end @@ -33,6 +34,7 @@ describe Gitlab::HealthChecks::PumaCheck do context 'when Puma is loaded' do before do + allow(Gitlab::Runtime).to receive(:puma?).and_return(true) stub_const('Puma', Module.new) end diff --git a/spec/lib/gitlab/health_checks/unicorn_check_spec.rb b/spec/lib/gitlab/health_checks/unicorn_check_spec.rb index 931b61cb1681863267cb56b1b4d3facdb5a09182..7c57b6f1ca5c997af6a7cf52f3f8de11c62e4abc 100644 --- a/spec/lib/gitlab/health_checks/unicorn_check_spec.rb +++ b/spec/lib/gitlab/health_checks/unicorn_check_spec.rb @@ -26,6 +26,7 @@ describe Gitlab::HealthChecks::UnicornCheck do context 'when Unicorn is not loaded' do before do + allow(Gitlab::Runtime).to receive(:unicorn?).and_return(false) hide_const('Unicorn') end @@ -39,6 +40,7 @@ describe Gitlab::HealthChecks::UnicornCheck do let(:http_server_class) { Struct.new(:worker_processes) } before do + allow(Gitlab::Runtime).to receive(:unicorn?).and_return(true) stub_const('Unicorn::HttpServer', http_server_class) end diff --git a/spec/lib/gitlab/highlight_spec.rb b/spec/lib/gitlab/highlight_spec.rb index 5a45d724b83ed865f7c3c855999cc54fecae92fe..2140cbae488b6b19db27561436216f47d11dee39 100644 --- a/spec/lib/gitlab/highlight_spec.rb +++ b/spec/lib/gitlab/highlight_spec.rb @@ -111,7 +111,7 @@ describe Gitlab::Highlight do end it 'utilizes longer timeout for sidekiq' do - allow(Sidekiq).to receive(:server?).and_return(true) + allow(Gitlab::Runtime).to receive(:sidekiq?).and_return(true) expect(Timeout).to receive(:timeout).with(described_class::TIMEOUT_BACKGROUND).and_call_original subject.highlight("Content") diff --git a/spec/lib/gitlab/import/merge_request_helpers_spec.rb b/spec/lib/gitlab/import/merge_request_helpers_spec.rb index 42515888d4ffc6a977519e221f8e0bd06ca5e6d5..2b16599415279c18239f4d652bba59a156ee3dd8 100644 --- a/spec/lib/gitlab/import/merge_request_helpers_spec.rb +++ b/spec/lib/gitlab/import/merge_request_helpers_spec.rb @@ -19,8 +19,7 @@ describe Gitlab::Import::MergeRequestHelpers, type: :helper do source_branch: 'master-42', target_branch: 'master', state_id: 3, - author_id: user.id, - assignee_id: user.id + author_id: user.id } end diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml index 817aedc19b0ca9c6306568541977d4de5322b974..08e57e541a40188d6ea1bf4986c1ab54dee6f0d5 100644 --- a/spec/lib/gitlab/import_export/all_models.yml +++ b/spec/lib/gitlab/import_export/all_models.yml @@ -6,8 +6,11 @@ issues: - assignees - updated_by - milestone +- issue_milestones +- milestones - notes - resource_label_events +- resource_weight_events - sentry_issue - label_links - labels @@ -35,6 +38,8 @@ issues: - vulnerability_links - related_vulnerabilities - user_mentions +- blocked_by_issue_links +- blocked_by_issues events: - author - project @@ -77,6 +82,8 @@ milestone: - boards - milestone_releases - releases +- issue_milestones +- merge_request_milestones snippets: - author - project @@ -105,6 +112,8 @@ merge_requests: - assignee - updated_by - milestone +- merge_request_milestones +- milestones - notes - resource_label_events - label_links @@ -145,6 +154,12 @@ merge_requests: - deployment_merge_requests - deployments - user_mentions +issue_milestones: +- milestone +- issue +merge_request_milestones: +- milestone +- merge_request external_pull_requests: - project merge_request_diff: @@ -188,16 +203,20 @@ ci_pipelines: - sourced_pipelines - triggered_by_pipeline - triggered_pipelines +- child_pipelines +- parent_pipeline - downstream_bridges - job_artifacts - vulnerabilities_occurrence_pipelines - vulnerability_findings +- pipeline_config pipeline_variables: - pipeline stages: - project - pipeline - statuses +- processables - builds - bridges statuses: @@ -446,6 +465,8 @@ project: - service_desk_setting - import_failures - container_expiration_policy +- resource_groups +- autoclose_referenced_issues award_emoji: - awardable - user @@ -560,3 +581,30 @@ zoom_meetings: sentry_issue: - issue design_versions: *version +epic: +- subscriptions +- award_emoji +- description_versions +- author +- assignee +- issues +- epic_issues +- milestone +- notes +- label_links +- labels +- todos +- metrics +- group +- parent +- children +- updated_by +- last_edited_by +- closed_by +- start_date_sourcing_milestone +- due_date_sourcing_milestone +- start_date_sourcing_epic +- due_date_sourcing_epic +- events +- resource_label_events +- user_mentions \ No newline at end of file diff --git a/spec/lib/gitlab/import_export/base_object_builder_spec.rb b/spec/lib/gitlab/import_export/base_object_builder_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..fbb3b08cf56159ffd78b1fde27d35e44cb409154 --- /dev/null +++ b/spec/lib/gitlab/import_export/base_object_builder_spec.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::ImportExport::BaseObjectBuilder do + let(:project) do + create(:project, :repository, + :builds_disabled, + :issues_disabled, + name: 'project', + path: 'project') + end + let(:klass) { Milestone } + let(:attributes) { { 'title' => 'Test BaseObjectBuilder Milestone', 'project' => project } } + + subject { described_class.build(klass, attributes) } + + describe '#build' do + context 'when object exists' do + context 'when where_clauses are implemented' do + before do + allow_next_instance_of(described_class) do |object_builder| + allow(object_builder).to receive(:where_clauses).and_return([klass.arel_table['title'].eq(attributes['title'])]) + end + end + + let!(:milestone) { create(:milestone, title: attributes['title'], project: project) } + + it 'finds existing object instead of creating one' do + expect(subject).to eq(milestone) + end + end + + context 'when where_clauses are not implemented' do + it 'raises NotImplementedError' do + expect { subject }.to raise_error(NotImplementedError) + end + end + end + + context 'when object does not exist' do + before do + allow_next_instance_of(described_class) do |object_builder| + allow(object_builder).to receive(:find_object).and_return(nil) + end + end + + it 'creates new object' do + expect { subject }.to change { Milestone.count }.from(0).to(1) + end + end + end +end diff --git a/spec/lib/gitlab/import_export/base_relation_factory_spec.rb b/spec/lib/gitlab/import_export/base_relation_factory_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..def3e43de9bd8356ee20b5e45c8e7ba99d0baeed --- /dev/null +++ b/spec/lib/gitlab/import_export/base_relation_factory_spec.rb @@ -0,0 +1,145 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::ImportExport::BaseRelationFactory do + let(:user) { create(:admin) } + let(:project) { create(:project) } + let(:members_mapper) { double('members_mapper').as_null_object } + let(:relation_sym) { :project_snippets } + let(:merge_requests_mapping) { {} } + let(:relation_hash) { {} } + let(:excluded_keys) { [] } + + subject do + described_class.create(relation_sym: relation_sym, + relation_hash: relation_hash, + object_builder: Gitlab::ImportExport::GroupProjectObjectBuilder, + members_mapper: members_mapper, + merge_requests_mapping: merge_requests_mapping, + user: user, + importable: project, + excluded_keys: excluded_keys) + end + + describe '#create' do + context 'when relation is invalid' do + before do + expect_next_instance_of(described_class) do |relation_factory| + expect(relation_factory).to receive(:invalid_relation?).and_return(true) + end + end + + it 'returns without creating new relations' do + expect(subject).to be_nil + end + end + + context 'when #setup_models is not implemented' do + it 'raises NotImplementedError' do + expect { subject }.to raise_error(NotImplementedError) + end + end + + context 'when #setup_models is implemented' do + let(:relation_sym) { :notes } + let(:relation_hash) do + { + "id" => 4947, + "note" => "merged", + "noteable_type" => "MergeRequest", + "author_id" => 999, + "created_at" => "2016-11-18T09:29:42.634Z", + "updated_at" => "2016-11-18T09:29:42.634Z", + "project_id" => 1, + "attachment" => { + "url" => nil + }, + "noteable_id" => 377, + "system" => true, + "events" => [] + } + end + + before do + expect_next_instance_of(described_class) do |relation_factory| + expect(relation_factory).to receive(:setup_models).and_return(true) + end + end + + it 'creates imported object' do + expect(subject).to be_instance_of(Note) + end + + context 'when relation contains user references' do + let(:new_user) { create(:user) } + let(:exported_member) do + { + "id" => 111, + "access_level" => 30, + "source_id" => 1, + "source_type" => "Project", + "user_id" => 3, + "notification_level" => 3, + "created_at" => "2016-11-18T09:29:42.634Z", + "updated_at" => "2016-11-18T09:29:42.634Z", + "user" => { + "id" => 999, + "email" => new_user.email, + "username" => new_user.username + } + } + end + + let(:members_mapper) do + Gitlab::ImportExport::MembersMapper.new( + exported_members: [exported_member], + user: user, + importable: project) + end + + it 'maps the right author to the imported note' do + expect(subject.author).to eq(new_user) + end + end + + context 'when relation contains token attributes' do + let(:relation_sym) { 'ProjectHook' } + let(:relation_hash) { { token: 'secret' } } + + it 'removes token attributes' do + expect(subject.token).to be_nil + end + end + + context 'when relation contains encrypted attributes' do + let(:relation_sym) { 'Ci::Variable' } + let(:relation_hash) do + create(:ci_variable).as_json + end + + it 'removes encrypted attributes' do + expect(subject.value).to be_nil + end + end + end + end + + describe '.relation_class' do + context 'when relation name is pluralized' do + let(:relation_name) { 'MergeRequest::Metrics' } + + it 'returns constantized class' do + expect(described_class.relation_class(relation_name)).to eq(MergeRequest::Metrics) + end + end + + context 'when relation name is singularized' do + let(:relation_name) { 'Badge' } + + it 'returns constantized class' do + expect(described_class.relation_class(relation_name)).to eq(Badge) + end + end + end +end diff --git a/spec/lib/gitlab/import_export/group_project_object_builder_spec.rb b/spec/lib/gitlab/import_export/group_project_object_builder_spec.rb index 0d0a2df4423f8a8eb98f57c16a2755441050613f..355757654daa39bd13c427032b7e8e7537c4c6e7 100644 --- a/spec/lib/gitlab/import_export/group_project_object_builder_spec.rb +++ b/spec/lib/gitlab/import_export/group_project_object_builder_spec.rb @@ -12,6 +12,59 @@ describe Gitlab::ImportExport::GroupProjectObjectBuilder do group: create(:group)) end + let(:lru_cache) { subject.send(:lru_cache) } + let(:cache_key) { subject.send(:cache_key) } + + context 'request store is not active' do + subject do + described_class.new(Label, + 'title' => 'group label', + 'project' => project, + 'group' => project.group) + end + + it 'ignore cache initialize' do + expect(lru_cache).to be_nil + expect(cache_key).to be_nil + end + end + + context 'request store is active', :request_store do + subject do + described_class.new(Label, + 'title' => 'group label', + 'project' => project, + 'group' => project.group) + end + + it 'initialize cache in memory' do + expect(lru_cache).not_to be_nil + expect(cache_key).not_to be_nil + end + + it 'cache object when first time find the object' do + group_label = create(:group_label, name: 'group label', group: project.group) + + expect(subject).to receive(:find_object).and_call_original + expect { subject.find } + .to change { lru_cache[cache_key] } + .from(nil).to(group_label) + + expect(subject.find).to eq(group_label) + end + + it 'read from cache when object has been cached' do + group_label = create(:group_label, name: 'group label', group: project.group) + + subject.find + + expect(subject).not_to receive(:find_object) + expect { subject.find }.not_to change { lru_cache[cache_key] } + + expect(subject.find).to eq(group_label) + end + end + context 'labels' do it 'finds the existing group label' do group_label = create(:group_label, name: 'group label', group: project.group) diff --git a/spec/lib/gitlab/import_export/import_failure_service_spec.rb b/spec/lib/gitlab/import_export/import_failure_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..0351f88afdb95532be8b425a71c87ef8b68ce130 --- /dev/null +++ b/spec/lib/gitlab/import_export/import_failure_service_spec.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::ImportExport::ImportFailureService do + let(:importable) { create(:project, :builds_enabled, :issues_disabled, name: 'project', path: 'project') } + let(:label) { create(:label) } + let(:subject) { described_class.new(importable) } + let(:relation_key) { "labels" } + let(:relation_index) { 0 } + + describe '#log_import_failure' do + let(:standard_error_message) { "StandardError message" } + let(:exception) { StandardError.new(standard_error_message) } + let(:correlation_id) { 'my-correlation-id' } + let(:retry_count) { 2 } + let(:log_import_failure) do + subject.log_import_failure(relation_key, relation_index, exception, retry_count) + end + + before do + # Import is running from the rake task, `correlation_id` is not assigned + allow(Labkit::Correlation::CorrelationId).to receive(:current_or_new_id).and_return(correlation_id) + end + + context 'when importable is a group' do + let(:importable) { create(:group) } + + it_behaves_like 'log import failure', :group_id + end + + context 'when importable is a project' do + it_behaves_like 'log import failure', :project_id + end + + context 'when ImportFailure does not support importable class' do + let(:importable) { create(:merge_request) } + + it 'raise exception' do + expect { subject }.to raise_exception(ActiveRecord::AssociationNotFoundError, "Association named 'import_failures' was not found on MergeRequest; perhaps you misspelled it?") + end + end + end + + describe '#with_retry' do + let(:perform_retry) do + subject.with_retry(relation_key, relation_index) do + label.save! + end + end + + context 'when exceptions are retriable' do + where(:exception) { Gitlab::ImportExport::ImportFailureService::RETRIABLE_EXCEPTIONS } + + with_them do + context 'when retry succeeds' do + before do + expect(label).to receive(:save!).and_raise(exception.new) + expect(label).to receive(:save!).and_return(true) + end + + it 'retries and logs import failure once with correct params' do + expect(subject).to receive(:log_import_failure).with(relation_key, relation_index, instance_of(exception), 1).once + + perform_retry + end + end + + context 'when retry continues to fail with intermittent errors' do + let(:maximum_retry_count) do + Retriable.config.tries + end + + before do + expect(label).to receive(:save!) + .exactly(maximum_retry_count).times + .and_raise(exception.new) + end + + it 'retries the number of times allowed and raise exception', :aggregate_failures do + expect { perform_retry }.to raise_exception(exception) + end + + it 'logs import failure each time and raise exception', :aggregate_failures do + maximum_retry_count.times do |index| + retry_count = index + 1 + + expect(subject).to receive(:log_import_failure).with(relation_key, relation_index, instance_of(exception), retry_count) + end + + expect { perform_retry }.to raise_exception(exception) + end + end + end + end + + context 'when exception is not retriable' do + let(:exception) { StandardError.new } + + it 'raise the exception', :aggregate_failures do + expect(label).to receive(:save!).once.and_raise(exception) + expect(subject).not_to receive(:log_import_failure) + expect { perform_retry }.to raise_exception(exception) + end + end + end +end diff --git a/spec/lib/gitlab/import_export/import_test_coverage_spec.rb b/spec/lib/gitlab/import_export/import_test_coverage_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..97d5ce07d47cf10e56fc00efbf50e52f0b5d4479 --- /dev/null +++ b/spec/lib/gitlab/import_export/import_test_coverage_spec.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +require 'spec_helper' + +# We want to test Import on "complete" data set, +# which means that every relation (as in our Import/Export definition) is covered. +# Fixture JSONs we use for testing Import such as +# `spec/fixtures/lib/gitlab/import_export/complex/project.json` +# should include these relations being non-empty. +describe 'Test coverage of the Project Import' do + include ConfigurationHelper + + # `MUTED_RELATIONS` is a technical debt. + # This list expected to be empty or used as a workround + # in case this spec blocks an important urgent MR. + # It is also expected that adding a relation in the list should lead to + # opening a follow-up issue to fix this. + MUTED_RELATIONS = %w[ + project.milestones.events.push_event_payload + project.issues.events + project.issues.events.push_event_payload + project.issues.notes.events + project.issues.notes.events.push_event_payload + project.issues.milestone.events.push_event_payload + project.issues.issue_milestones + project.issues.issue_milestones.milestone + project.issues.resource_label_events.label.priorities + project.issues.designs.notes + project.issues.designs.notes.author + project.issues.designs.notes.events + project.issues.designs.notes.events.push_event_payload + project.merge_requests.metrics + project.merge_requests.notes.events.push_event_payload + project.merge_requests.events.push_event_payload + project.merge_requests.timelogs + project.merge_requests.label_links + project.merge_requests.label_links.label + project.merge_requests.label_links.label.priorities + project.merge_requests.milestone + project.merge_requests.milestone.events + project.merge_requests.milestone.events.push_event_payload + project.merge_requests.merge_request_milestones + project.merge_requests.merge_request_milestones.milestone + project.merge_requests.resource_label_events.label + project.merge_requests.resource_label_events.label.priorities + project.ci_pipelines.notes.events + project.ci_pipelines.notes.events.push_event_payload + project.protected_branches.unprotect_access_levels + project.prometheus_metrics + project.metrics_setting + project.boards.lists.label.priorities + project.service_desk_setting + ].freeze + + # A list of JSON fixture files we use to test Import. + # Note that we use separate fixture to test ee-only features. + # Most of the relations are present in `complex/project.json` + # which is our main fixture. + PROJECT_JSON_FIXTURES_EE = + if Gitlab.ee? + ['ee/spec/fixtures/lib/gitlab/import_export/designs/project.json'].freeze + else + [] + end + + PROJECT_JSON_FIXTURES = [ + 'spec/fixtures/lib/gitlab/import_export/complex/project.json', + 'spec/fixtures/lib/gitlab/import_export/group/project.json', + 'spec/fixtures/lib/gitlab/import_export/light/project.json', + 'spec/fixtures/lib/gitlab/import_export/milestone-iid/project.json' + ].freeze + PROJECT_JSON_FIXTURES_EE + + it 'ensures that all imported/exported relations are present in test JSONs' do + not_tested_relations = (relations_from_config - tested_relations) - MUTED_RELATIONS + + expect(not_tested_relations).to be_empty, failure_message(not_tested_relations) + end + + def relations_from_config + relation_paths_for(:project) + .map { |relation_names| relation_names.join(".") } + .to_set + end + + def tested_relations + PROJECT_JSON_FIXTURES.flat_map(&method(:relations_from_json)).to_set + end + + def relations_from_json(json_file) + json = ActiveSupport::JSON.decode(IO.read(json_file)) + + Gitlab::ImportExport::RelationRenameService.rename(json) + + [].tap {|res| gather_relations({ project: json }, res, [])} + .map {|relation_names| relation_names.join('.')} + end + + def gather_relations(item, res, path) + case item + when Hash + item.each do |k, v| + if (v.is_a?(Array) || v.is_a?(Hash)) && v.present? + new_path = path + [k] + res << new_path + gather_relations(v, res, new_path) + end + end + when Array + item.each {|i| gather_relations(i, res, path)} + end + end + + def failure_message(not_tested_relations) + <<~MSG + These relations seem to be added recenty and + they expected to be covered in our Import specs: #{not_tested_relations}. + + To do that, expand one of the files listed in `PROJECT_JSON_FIXTURES` + (or expand the list if you consider adding a new fixture file). + + After that, add a new spec into + `spec/lib/gitlab/import_export/project_tree_restorer_spec.rb` + to check that the relation is being imported correctly. + + In case the spec breaks the master or there is a sense of urgency, + you could include the relations into the `MUTED_RELATIONS` list. + + Muting relations is considered to be a temporary solution, so please + open a follow-up issue and try to fix that when it is possible. + MSG + end +end diff --git a/spec/lib/gitlab/import_export/relation_factory_spec.rb b/spec/lib/gitlab/import_export/project_relation_factory_spec.rb similarity index 64% rename from spec/lib/gitlab/import_export/relation_factory_spec.rb rename to spec/lib/gitlab/import_export/project_relation_factory_spec.rb index 41d6e6f24fc254e66a37e06683f582d25a71c76e..0ade7ac4fc7d30965f733efea681aaf1f3e49c26 100644 --- a/spec/lib/gitlab/import_export/relation_factory_spec.rb +++ b/spec/lib/gitlab/import_export/project_relation_factory_spec.rb @@ -2,8 +2,9 @@ require 'spec_helper' -describe Gitlab::ImportExport::RelationFactory do - let(:project) { create(:project) } +describe Gitlab::ImportExport::ProjectRelationFactory do + let(:group) { create(:group) } + let(:project) { create(:project, :repository, group: group) } let(:members_mapper) { double('members_mapper').as_null_object } let(:merge_requests_mapping) { {} } let(:user) { create(:admin) } @@ -11,10 +12,11 @@ describe Gitlab::ImportExport::RelationFactory do let(:created_object) do described_class.create(relation_sym: relation_sym, relation_hash: relation_hash, + object_builder: Gitlab::ImportExport::GroupProjectObjectBuilder, members_mapper: members_mapper, merge_requests_mapping: merge_requests_mapping, user: user, - project: project, + importable: project, excluded_keys: excluded_keys) end @@ -59,7 +61,7 @@ describe Gitlab::ImportExport::RelationFactory do end it 'has the new project_id' do - expect(created_object.project_id).to eq(project.id) + expect(created_object.project_id).to eql(project.id) end it 'has a nil token' do @@ -96,6 +98,100 @@ describe Gitlab::ImportExport::RelationFactory do end end + context 'merge_request object' do + let(:relation_sym) { :merge_requests } + + let(:exported_member) do + { + "id" => 111, + "access_level" => 30, + "source_id" => 1, + "source_type" => "Project", + "user_id" => 3, + "notification_level" => 3, + "created_at" => "2016-11-18T09:29:42.634Z", + "updated_at" => "2016-11-18T09:29:42.634Z", + "user" => { + "id" => user.id, + "email" => user.email, + "username" => user.username + } + } + end + + let(:members_mapper) do + Gitlab::ImportExport::MembersMapper.new( + exported_members: [exported_member], + user: user, + importable: project) + end + + let(:relation_hash) do + { + 'id' => 27, + 'target_branch' => "feature", + 'source_branch' => "feature_conflict", + 'source_project_id' => project.id, + 'target_project_id' => project.id, + 'author_id' => user.id, + 'assignee_id' => user.id, + 'updated_by_id' => user.id, + 'title' => "MR1", + 'created_at' => "2016-06-14T15:02:36.568Z", + 'updated_at' => "2016-06-14T15:02:56.815Z", + 'state' => "opened", + 'merge_status' => "unchecked", + 'description' => "Description", + 'position' => 0, + 'source_branch_sha' => "ABCD", + 'target_branch_sha' => "DCBA", + 'merge_when_pipeline_succeeds' => true + } + end + + it 'has preloaded author' do + expect(created_object.author).to equal(user) + end + + it 'has preloaded updated_by' do + expect(created_object.updated_by).to equal(user) + end + + it 'has preloaded source project' do + expect(created_object.source_project).to equal(project) + end + + it 'has preloaded target project' do + expect(created_object.source_project).to equal(project) + end + end + + context 'label object' do + let(:relation_sym) { :labels } + let(:relation_hash) do + { + "id": 3, + "title": "test3", + "color": "#428bca", + "group_id": project.group.id, + "created_at": "2016-07-22T08:55:44.161Z", + "updated_at": "2016-07-22T08:55:44.161Z", + "template": false, + "description": "", + "project_id": project.id, + "type": "GroupLabel" + } + end + + it 'has preloaded project' do + expect(created_object.project).to equal(project) + end + + it 'has preloaded group' do + expect(created_object.group).to equal(project.group) + end + end + # `project_id`, `described_class.USER_REFERENCES`, noteable_id, target_id, and some project IDs are already # re-assigned by described_class. context 'Potentially hazardous foreign keys' do @@ -118,6 +214,10 @@ describe Gitlab::ImportExport::RelationFactory do attr_accessor :service_id, :moved_to_id, :namespace_id, :ci_id, :random_project_id, :random_id, :milestone_id, :project_id end + before do + allow(HazardousFooModel).to receive(:reflect_on_association).and_return(nil) + end + it 'does not preserve any foreign key IDs' do expect(created_object.values).not_to include(99) end @@ -145,11 +245,15 @@ describe Gitlab::ImportExport::RelationFactory do context 'Project references' do let(:relation_sym) { :project_foo_model } let(:relation_hash) do - Gitlab::ImportExport::RelationFactory::PROJECT_REFERENCES.map { |ref| { ref => 99 } }.inject(:merge) + Gitlab::ImportExport::ProjectRelationFactory::PROJECT_REFERENCES.map { |ref| { ref => 99 } }.inject(:merge) end class ProjectFooModel < FooModel - attr_accessor(*Gitlab::ImportExport::RelationFactory::PROJECT_REFERENCES) + attr_accessor(*Gitlab::ImportExport::ProjectRelationFactory::PROJECT_REFERENCES) + end + + before do + allow(ProjectFooModel).to receive(:reflect_on_association).and_return(nil) end it 'does not preserve any project foreign key IDs' do 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 ec1b935ad63095cf1570124465b868400930c80c..ac9a63e84143d89d9b9bfeb50d066a3626c8716e 100644 --- a/spec/lib/gitlab/import_export/project_tree_restorer_spec.rb +++ b/spec/lib/gitlab/import_export/project_tree_restorer_spec.rb @@ -36,10 +36,6 @@ describe Gitlab::ImportExport::ProjectTreeRestorer do end context 'JSON' do - before do - stub_feature_flags(use_legacy_pipeline_triggers: false) - end - it 'restores models based on JSON' do expect(@restored_project_json).to be_truthy end @@ -120,6 +116,15 @@ describe Gitlab::ImportExport::ProjectTreeRestorer do expect(Issue.find_by(title: 'Issue without assignees').assignees).to be_empty end + it 'restores timelogs for issues' do + timelog = Issue.find_by(title: 'issue_with_timelogs').timelogs.last + + aggregate_failures do + expect(timelog.time_spent).to eq(72000) + expect(timelog.spent_at).to eq("2019-12-27T00:00:00.000Z") + end + end + it 'contains the merge access levels on a protected branch' do expect(ProtectedBranch.first.merge_access_levels).not_to be_empty end @@ -219,10 +224,25 @@ describe Gitlab::ImportExport::ProjectTreeRestorer do expect(award_emoji.map(&:name)).to contain_exactly('thumbsup', 'coffee') end + it 'snippet has notes' do + expect(@project.snippets.first.notes.count).to eq(1) + end + + it 'snippet has award emojis on notes' do + award_emoji = @project.snippets.first.notes.first.award_emoji.first + + expect(award_emoji.name).to eq('thumbsup') + end + it 'restores `ci_cd_settings` : `group_runners_enabled` setting' do expect(@project.ci_cd_settings.group_runners_enabled?).to eq(false) end + it 'restores `auto_devops`' do + expect(@project.auto_devops_enabled?).to eq(true) + expect(@project.auto_devops.deploy_strategy).to eq('continuous') + end + it 'restores the correct service' do expect(CustomIssueTrackerService.first).not_to be_nil end @@ -240,6 +260,18 @@ describe Gitlab::ImportExport::ProjectTreeRestorer do expect(sentry_issue.sentry_issue_identifier).to eq(1234567891) end + it 'has award emoji for an issue' do + award_emoji = @project.issues.first.award_emoji.first + + expect(award_emoji.name).to eq('musical_keyboard') + end + + it 'has award emoji for a note in an issue' do + award_emoji = @project.issues.first.notes.first.award_emoji.first + + expect(award_emoji.name).to eq('clapper') + end + it 'restores container_expiration_policy' do policy = Project.find_by_path('project').container_expiration_policy @@ -250,6 +282,55 @@ describe Gitlab::ImportExport::ProjectTreeRestorer do end end + it 'restores error_tracking_setting' do + setting = @project.error_tracking_setting + + aggregate_failures do + expect(setting.api_url).to eq("https://gitlab.example.com/api/0/projects/sentry-org/sentry-project") + expect(setting.project_name).to eq("Sentry Project") + expect(setting.organization_name).to eq("Sentry Org") + end + end + + it 'restores external pull requests' do + external_pr = @project.external_pull_requests.last + + aggregate_failures do + expect(external_pr.pull_request_iid).to eq(4) + expect(external_pr.source_branch).to eq("feature") + expect(external_pr.target_branch).to eq("master") + expect(external_pr.status).to eq("open") + end + end + + it 'restores pipeline schedules' do + pipeline_schedule = @project.pipeline_schedules.last + + aggregate_failures do + expect(pipeline_schedule.description).to eq('Schedule Description') + expect(pipeline_schedule.ref).to eq('master') + expect(pipeline_schedule.cron).to eq('0 4 * * 0') + expect(pipeline_schedule.cron_timezone).to eq('UTC') + expect(pipeline_schedule.active).to eq(true) + end + end + + it 'restores releases with links' do + release = @project.releases.last + link = release.links.last + + aggregate_failures do + expect(release.tag).to eq('release-1.1') + expect(release.description).to eq('Some release notes') + expect(release.name).to eq('release-1.1') + expect(release.sha).to eq('901de3a8bd5573f4a049b1457d28bc1592ba6bf9') + expect(release.released_at).to eq('2019-12-26T10:17:14.615Z') + + expect(link.url).to eq('http://localhost/namespace6/project6/-/jobs/140463678/artifacts/download') + expect(link.name).to eq('release-1.1.dmg') + end + end + context 'Merge requests' do it 'always has the new project as a target' do expect(MergeRequest.find_by_title('MR1').target_project).to eq(@project) @@ -266,6 +347,20 @@ describe Gitlab::ImportExport::ProjectTreeRestorer do it 'has no source if source/target differ' do expect(MergeRequest.find_by_title('MR2').source_project_id).to be_nil end + + it 'has award emoji' do + award_emoji = MergeRequest.find_by_title('MR1').award_emoji + + expect(award_emoji.map(&:name)).to contain_exactly('thumbsup', 'drum') + end + + context 'notes' do + it 'has award emoji' do + award_emoji = MergeRequest.find_by_title('MR1').notes.first.award_emoji.first + + expect(award_emoji.name).to eq('tada') + end + end end context 'tokens are regenerated' do @@ -289,9 +384,9 @@ describe Gitlab::ImportExport::ProjectTreeRestorer do end it 'has the correct number of pipelines and statuses' do - expect(@project.ci_pipelines.size).to eq(6) + expect(@project.ci_pipelines.size).to eq(7) - @project.ci_pipelines.order(:id).zip([2, 2, 2, 2, 2, 0]) + @project.ci_pipelines.order(:id).zip([2, 2, 2, 2, 2, 0, 0]) .each do |(pipeline, expected_status_size)| expect(pipeline.statuses.size).to eq(expected_status_size) end @@ -300,7 +395,7 @@ describe Gitlab::ImportExport::ProjectTreeRestorer do context 'when restoring hierarchy of pipeline, stages and jobs' do it 'restores pipelines' do - expect(Ci::Pipeline.all.count).to be 6 + expect(Ci::Pipeline.all.count).to be 7 end it 'restores pipeline stages' do @@ -326,6 +421,12 @@ describe Gitlab::ImportExport::ProjectTreeRestorer do it 'restores a Hash for CommitStatus options' do expect(CommitStatus.all.map(&:options).compact).to all(be_a(Hash)) end + + it 'restores external pull request for the restored pipeline' do + pipeline_with_external_pr = @project.ci_pipelines.order(:id).last + + expect(pipeline_with_external_pr.external_pull_request).to be_persisted + end end end end @@ -466,7 +567,7 @@ describe Gitlab::ImportExport::ProjectTreeRestorer do end it_behaves_like 'restores project successfully', - issues: 2, + issues: 3, labels: 2, label_with_priorities: 'A project label', milestones: 2, @@ -479,7 +580,7 @@ describe Gitlab::ImportExport::ProjectTreeRestorer do it 'restores issue states' do expect(project.issues.with_state(:closed).count).to eq(1) - expect(project.issues.with_state(:opened).count).to eq(1) + expect(project.issues.with_state(:opened).count).to eq(2) end end @@ -654,13 +755,10 @@ describe Gitlab::ImportExport::ProjectTreeRestorer do let(:user) { create(:user) } let!(:project) { create(:project, :builds_disabled, :issues_disabled, name: 'project', path: 'project') } let(:project_tree_restorer) { described_class.new(user: user, shared: shared, project: project) } - let(:correlation_id) { 'my-correlation-id' } before do setup_import_export_config('with_invalid_records') - # Import is running from the rake task, `correlation_id` is not assigned - expect(Labkit::Correlation::CorrelationId).to receive(:new_id).and_return(correlation_id) subject end @@ -682,7 +780,7 @@ describe Gitlab::ImportExport::ProjectTreeRestorer do expect(import_failure.relation_index).to be_present expect(import_failure.exception_class).to eq('ActiveRecord::RecordInvalid') expect(import_failure.exception_message).to be_present - expect(import_failure.correlation_id_value).to eq('my-correlation-id') + expect(import_failure.correlation_id_value).not_to be_empty expect(import_failure.created_at).to be_present end end diff --git a/spec/lib/gitlab/import_export/relation_tree_restorer_spec.rb b/spec/lib/gitlab/import_export/relation_tree_restorer_spec.rb index c761f9652abbed7f39f7e3882e1f77433791f7d6..edb2c0a131a847256864b0dc0cc5c54cc102d79f 100644 --- a/spec/lib/gitlab/import_export/relation_tree_restorer_spec.rb +++ b/spec/lib/gitlab/import_export/relation_tree_restorer_spec.rb @@ -27,6 +27,7 @@ describe Gitlab::ImportExport::RelationTreeRestorer do shared: shared, tree_hash: tree_hash, importable: importable, + object_builder: object_builder, members_mapper: members_mapper, relation_factory: relation_factory, reader: reader @@ -38,7 +39,8 @@ describe Gitlab::ImportExport::RelationTreeRestorer do context 'when restoring a project' do let(:path) { 'spec/fixtures/lib/gitlab/import_export/complex/project.json' } let(:importable) { create(:project, :builds_enabled, :issues_disabled, name: 'project', path: 'project') } - let(:relation_factory) { Gitlab::ImportExport::RelationFactory } + let(:object_builder) { Gitlab::ImportExport::GroupProjectObjectBuilder } + let(:relation_factory) { Gitlab::ImportExport::ProjectRelationFactory } let(:reader) { Gitlab::ImportExport::Reader.new(shared: shared) } let(:tree_hash) { importable_hash } diff --git a/spec/lib/gitlab/import_export/safe_model_attributes.yml b/spec/lib/gitlab/import_export/safe_model_attributes.yml index 79442c35797104cf4745d8017bd0dd6d9df85f1f..ad363233bfe537dd6af5e9cb2cec3622fbec2669 100644 --- a/spec/lib/gitlab/import_export/safe_model_attributes.yml +++ b/spec/lib/gitlab/import_export/safe_model_attributes.yml @@ -333,6 +333,7 @@ CommitStatus: - scheduled_at - upstream_pipeline_id - interruptible +- processed Ci::Variable: - id - project_id @@ -534,6 +535,8 @@ Project: - pages_https_only - merge_requests_disable_committers_approval - require_password_to_approve +- autoclose_referenced_issues +- suggestion_commit_message ProjectTracingSetting: - external_url Author: @@ -542,6 +545,7 @@ ProjectFeature: - id - project_id - merge_requests_access_level +- forking_access_level - issues_access_level - wiki_access_level - snippets_access_level @@ -764,3 +768,33 @@ ContainerExpirationPolicy: - older_than - keep_n - enabled +Epic: + - id + - milestone_id + - group_id + - author_id + - assignee_id + - iid + - updated_by_id + - last_edited_by_id + - lock_version + - start_date + - end_date + - last_edited_at + - created_at + - updated_at + - title + - description + - start_date_sourcing_milestone_id + - due_date_sourcing_milestone_id + - start_date_fixed + - due_date_fixed + - start_date_is_fixed + - due_date_is_fixed + - closed_by_id + - closed_at + - parent_id + - relative_position + - state_id + - start_date_sourcing_epic_id + - due_date_sourcing_epic_id diff --git a/spec/lib/gitlab/kubernetes/helm/api_spec.rb b/spec/lib/gitlab/kubernetes/helm/api_spec.rb index 5d9beec093a5b8ac00cadd3fb465888a8d34a1b8..e493acd7bad9aa0057bab16c80c9eed67e5b34e7 100644 --- a/spec/lib/gitlab/kubernetes/helm/api_spec.rb +++ b/spec/lib/gitlab/kubernetes/helm/api_spec.rb @@ -6,7 +6,8 @@ describe Gitlab::Kubernetes::Helm::Api do let(:client) { double('kubernetes client') } let(:helm) { described_class.new(client) } let(:gitlab_namespace) { Gitlab::Kubernetes::Helm::NAMESPACE } - let(:namespace) { Gitlab::Kubernetes::Namespace.new(gitlab_namespace, client) } + let(:gitlab_namespace_labels) { Gitlab::Kubernetes::Helm::NAMESPACE_LABELS } + let(:namespace) { Gitlab::Kubernetes::Namespace.new(gitlab_namespace, client, labels: gitlab_namespace_labels) } let(:application_name) { 'app-name' } let(:rbac) { false } let(:files) { {} } @@ -23,13 +24,17 @@ describe Gitlab::Kubernetes::Helm::Api do subject { helm } before do - allow(Gitlab::Kubernetes::Namespace).to receive(:new).with(gitlab_namespace, client).and_return(namespace) + allow(Gitlab::Kubernetes::Namespace).to( + receive(:new).with(gitlab_namespace, client, labels: gitlab_namespace_labels).and_return(namespace) + ) allow(client).to receive(:create_config_map) end describe '#initialize' do it 'creates a namespace object' do - expect(Gitlab::Kubernetes::Namespace).to receive(:new).with(gitlab_namespace, client) + expect(Gitlab::Kubernetes::Namespace).to( + receive(:new).with(gitlab_namespace, client, labels: gitlab_namespace_labels) + ) subject end diff --git a/spec/lib/gitlab/kubernetes/kube_client_spec.rb b/spec/lib/gitlab/kubernetes/kube_client_spec.rb index 59e81d89a5047716e9de9e7af28dd5b85870259d..e08981a3415277006a5ebcafcbd948f05d272a9b 100644 --- a/spec/lib/gitlab/kubernetes/kube_client_spec.rb +++ b/spec/lib/gitlab/kubernetes/kube_client_spec.rb @@ -136,6 +136,20 @@ describe Gitlab::Kubernetes::KubeClient do end end + describe '#istio_client' do + subject { client.istio_client } + + it_behaves_like 'a Kubeclient' + + it 'has the Istio API group endpoint' do + expect(subject.api_endpoint.to_s).to match(%r{\/apis\/networking.istio.io\Z}) + end + + it 'has the api_version' do + expect(subject.instance_variable_get(:@api_version)).to eq('v1alpha3') + end + end + describe '#knative_client' do subject { client.knative_client } @@ -233,6 +247,29 @@ describe Gitlab::Kubernetes::KubeClient do end end + describe 'istio API group' do + let(:istio_client) { client.istio_client } + + [ + :create_gateway, + :get_gateway, + :update_gateway + ].each do |method| + describe "##{method}" do + include_examples 'redirection not allowed', method + include_examples 'dns rebinding not allowed', method + + it 'delegates to the istio client' do + expect(client).to delegate_method(method).to(:istio_client) + end + + it 'responds to the method' do + expect(client).to respond_to method + end + end + end + end + describe 'non-entity methods' do it 'does not proxy for non-entity methods' do expect(client).not_to respond_to :proxy_url diff --git a/spec/lib/gitlab/kubernetes/namespace_spec.rb b/spec/lib/gitlab/kubernetes/namespace_spec.rb index 16634cc48e6f9ffac9cae6c047206609fe9bafbe..d44a803410fc448726be744503ed6c23eaccd3ff 100644 --- a/spec/lib/gitlab/kubernetes/namespace_spec.rb +++ b/spec/lib/gitlab/kubernetes/namespace_spec.rb @@ -5,8 +5,9 @@ require 'spec_helper' describe Gitlab::Kubernetes::Namespace do let(:name) { 'a_namespace' } let(:client) { double('kubernetes client') } + let(:labels) { nil } - subject { described_class.new(name, client) } + subject { described_class.new(name, client, labels: labels) } it { expect(subject.name).to eq(name) } @@ -49,6 +50,17 @@ describe Gitlab::Kubernetes::Namespace do expect { subject.create! }.not_to raise_error end + + context 'with labels' do + let(:labels) { { foo: :bar } } + + it 'creates a namespace with labels' do + matcher = have_attributes(metadata: have_attributes(name: name, labels: have_attributes(foo: :bar))) + expect(client).to receive(:create_namespace).with(matcher).once + + expect { subject.create! }.not_to raise_error + end + end end describe '#ensure_exists!' do diff --git a/spec/lib/gitlab/metrics/dashboard/processor_spec.rb b/spec/lib/gitlab/metrics/dashboard/processor_spec.rb index 4fa136bc405af3e9887b922ba485c0490c350e32..e186a383059244c9035648eac965d34ec0128e93 100644 --- a/spec/lib/gitlab/metrics/dashboard/processor_spec.rb +++ b/spec/lib/gitlab/metrics/dashboard/processor_spec.rb @@ -86,6 +86,16 @@ describe Gitlab::Metrics::Dashboard::Processor do expect(metrics).to eq %w(metric_b metric_a2 metric_a1) end end + + context 'when sample_metrics are requested' do + let(:process_params) { [project, dashboard_yml, sequence, { environment: environment, sample_metrics: true }] } + + it 'includes a sample metrics path for the prometheus endpoint with each metric' do + expect(all_metrics).to satisfy_all do |metric| + metric[:prometheus_endpoint_path] == sample_metrics_path(metric[:id]) + end + end + end end shared_examples_for 'errors with message' do |expected_message| @@ -147,4 +157,12 @@ describe Gitlab::Metrics::Dashboard::Processor do query: query ) end + + def sample_metrics_path(metric) + Gitlab::Routing.url_helpers.sample_metrics_project_environment_path( + project, + environment, + identifier: metric + ) + end end diff --git a/spec/lib/gitlab/metrics/samplers/influx_sampler_spec.rb b/spec/lib/gitlab/metrics/samplers/influx_sampler_spec.rb index 2d4b27a6ac1bbc56b61d76ba31f83db77e8ad75b..939c057c3426aa2ec4b47bb2914e1f41b87fba02 100644 --- a/spec/lib/gitlab/metrics/samplers/influx_sampler_spec.rb +++ b/spec/lib/gitlab/metrics/samplers/influx_sampler_spec.rb @@ -63,7 +63,7 @@ describe Gitlab::Metrics::Samplers::InfluxSampler do describe '#add_metric' do it 'prefixes the series name for a Rails process' do - expect(sampler).to receive(:sidekiq?).and_return(false) + expect(Gitlab::Runtime).to receive(:sidekiq?).and_return(false) expect(Gitlab::Metrics::Metric).to receive(:new) .with('rails_cats', { value: 10 }, {}) @@ -73,7 +73,7 @@ describe Gitlab::Metrics::Samplers::InfluxSampler do end it 'prefixes the series name for a Sidekiq process' do - expect(sampler).to receive(:sidekiq?).and_return(true) + expect(Gitlab::Runtime).to receive(:sidekiq?).and_return(true) expect(Gitlab::Metrics::Metric).to receive(:new) .with('sidekiq_cats', { value: 10 }, {}) diff --git a/spec/lib/gitlab/middleware/request_context_spec.rb b/spec/lib/gitlab/middleware/request_context_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..1ed06a97c1ea8ab0c2c738a7c59816307c9c2816 --- /dev/null +++ b/spec/lib/gitlab/middleware/request_context_spec.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true +require 'fast_spec_helper' +require 'rack' +require 'request_store' +require_relative '../../../support/helpers/next_instance_of' + +describe Gitlab::Middleware::RequestContext do + include NextInstanceOf + + let(:app) { -> (env) {} } + let(:env) { {} } + + around do |example| + RequestStore.begin! + example.run + RequestStore.end! + RequestStore.clear! + end + + describe '#call' do + context 'setting the client ip' do + subject { Gitlab::RequestContext.instance.client_ip } + + context 'with X-Forwarded-For headers' do + let(:load_balancer_ip) { '1.2.3.4' } + let(:headers) do + { + 'HTTP_X_FORWARDED_FOR' => "#{load_balancer_ip}, 127.0.0.1", + 'REMOTE_ADDR' => '127.0.0.1' + } + end + + let(:env) { Rack::MockRequest.env_for("/").merge(headers) } + + it 'returns the load balancer IP' do + endpoint = proc do + [200, {}, ["Hello"]] + end + + described_class.new(endpoint).call(env) + + expect(subject).to eq(load_balancer_ip) + end + end + + context 'request' do + let(:ip) { '192.168.1.11' } + + before do + allow_next_instance_of(Rack::Request) do |instance| + allow(instance).to receive(:ip).and_return(ip) + end + described_class.new(app).call(env) + end + + it { is_expected.to eq(ip) } + end + + context 'before RequestContext middleware run' do + it { is_expected.to be_nil } + end + end + end + + context 'setting the thread cpu time' do + it 'sets the `start_thread_cpu_time`' do + expect { described_class.new(app).call(env) } + .to change { Gitlab::RequestContext.instance.start_thread_cpu_time }.from(nil).to(Float) + end + end + + context 'setting the request start time' do + it 'sets the `request_start_time`' do + expect { described_class.new(app).call(env) } + .to change { Gitlab::RequestContext.instance.request_start_time }.from(nil).to(Float) + end + end +end diff --git a/spec/lib/gitlab/multi_destination_logger_spec.rb b/spec/lib/gitlab/multi_destination_logger_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..7acd7906a26831963d9a26b5c53d119a5ebbf245 --- /dev/null +++ b/spec/lib/gitlab/multi_destination_logger_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require 'spec_helper' + +class FakeLogger +end + +class LoggerA < Gitlab::Logger + def self.file_name_noext + 'loggerA' + end +end + +class LoggerB < Gitlab::JsonLogger + def self.file_name_noext + 'loggerB' + end +end + +class TestLogger < Gitlab::MultiDestinationLogger + LOGGERS = [LoggerA, LoggerB].freeze + + def self.loggers + LOGGERS + end +end + +class EmptyLogger < Gitlab::MultiDestinationLogger + def self.loggers + [] + end +end + +describe Gitlab::MultiDestinationLogger do + after(:all) do + TestLogger.loggers.each do |logger| + log_file_path = "#{Rails.root}/log/#{logger.file_name}" + File.delete(log_file_path) + end + end + + context 'with no primary logger set' do + subject { EmptyLogger } + + it 'primary_logger raises an error' do + expect { subject.primary_logger }.to raise_error(NotImplementedError) + end + end + + context 'with 2 loggers set' do + subject { TestLogger } + + it 'logs info to 2 loggers' do + expect(subject.loggers).to all(receive(:build).and_call_original) + + subject.info('Hello World') + end + end +end diff --git a/spec/lib/gitlab/pages_spec.rb b/spec/lib/gitlab/pages_spec.rb index aecbc74385efa89250f514b242d390f4aaaa9ba4..5889689cb81e060c90b487d318935f23a4cf9c3c 100644 --- a/spec/lib/gitlab/pages_spec.rb +++ b/spec/lib/gitlab/pages_spec.rb @@ -3,6 +3,8 @@ require 'spec_helper' describe Gitlab::Pages do + using RSpec::Parameterized::TableSyntax + let(:pages_secret) { SecureRandom.random_bytes(Gitlab::Pages::SECRET_LENGTH) } before do @@ -26,4 +28,24 @@ describe Gitlab::Pages do expect(described_class.verify_api_request(headers)).to eq([{ "iss" => "gitlab-pages" }, { "alg" => "HS256" }]) end end + + describe '.access_control_is_forced?' do + subject { described_class.access_control_is_forced? } + + where(:access_control_is_enabled, :access_control_is_forced, :result) do + false | false | false + false | true | false + true | false | false + true | true | true + end + + with_them do + before do + stub_pages_setting(access_control: access_control_is_enabled) + stub_application_setting(force_pages_access_control: access_control_is_forced) + end + + it { is_expected.to eq(result) } + end + end end diff --git a/spec/lib/gitlab/pagination/keyset/page_spec.rb b/spec/lib/gitlab/pagination/keyset/page_spec.rb index 5c03224c05a55f7cc50defe3c0fa6ec445beef8a..c5ca27231d86c10ed08ce1dadf1091d47fab1e51 100644 --- a/spec/lib/gitlab/pagination/keyset/page_spec.rb +++ b/spec/lib/gitlab/pagination/keyset/page_spec.rb @@ -30,16 +30,14 @@ describe Gitlab::Pagination::Keyset::Page do end describe '#next' do - let(:page) { described_class.new(order_by: order_by, lower_bounds: lower_bounds, per_page: per_page, end_reached: end_reached) } - subject { page.next(new_lower_bounds, new_end_reached) } + let(:page) { described_class.new(order_by: order_by, lower_bounds: lower_bounds, per_page: per_page) } + subject { page.next(new_lower_bounds) } let(:order_by) { { id: :desc } } let(:lower_bounds) { { id: 42 } } let(:per_page) { 10 } - let(:end_reached) { false } let(:new_lower_bounds) { { id: 21 } } - let(:new_end_reached) { true } it 'copies over order_by' do expect(subject.order_by).to eq(page.order_by) @@ -57,10 +55,5 @@ describe Gitlab::Pagination::Keyset::Page do expect(subject.lower_bounds).to eq(new_lower_bounds) expect(page.lower_bounds).to eq(lower_bounds) end - - it 'sets end_reached only on new instance' do - expect(subject.end_reached?).to eq(new_end_reached) - expect(page.end_reached?).to eq(end_reached) - end end end diff --git a/spec/lib/gitlab/pagination/keyset/pager_spec.rb b/spec/lib/gitlab/pagination/keyset/pager_spec.rb index 6d23fe2adcc85cbae32ea7fda76374302000baad..3ad1bee722533193b2ce2d70751c2e381e925903 100644 --- a/spec/lib/gitlab/pagination/keyset/pager_spec.rb +++ b/spec/lib/gitlab/pagination/keyset/pager_spec.rb @@ -15,15 +15,37 @@ describe Gitlab::Pagination::Keyset::Pager do describe '#paginate' do subject { described_class.new(request).paginate(relation) } - it 'loads the result relation only once' do + it 'does not execute a query' do expect do subject - end.not_to exceed_query_limit(1) + end.not_to exceed_query_limit(0) end + it 'applies a LIMIT' do + expect(subject.limit_value).to eq(page.per_page) + end + + it 'returns the limited relation' do + expect(subject).to eq(relation.limit(page.per_page)) + end + + context 'validating the order clause' do + let(:page) { Gitlab::Pagination::Keyset::Page.new(order_by: { created_at: :asc }, per_page: 3) } + + it 'raises an error if has a different order clause than the page' do + expect { subject }.to raise_error(ArgumentError, /order_by does not match/) + end + end + end + + describe '#finalize' do + let(:records) { relation.limit(page.per_page).load } + + subject { described_class.new(request).finalize(records) } + it 'passes information about next page to request' do - lower_bounds = relation.limit(page.per_page).last.slice(:id) - expect(page).to receive(:next).with(lower_bounds, false).and_return(next_page) + lower_bounds = records.last.slice(:id) + expect(page).to receive(:next).with(lower_bounds).and_return(next_page) expect(request).to receive(:apply_headers).with(next_page) subject @@ -32,10 +54,10 @@ describe Gitlab::Pagination::Keyset::Pager do context 'when retrieving the last page' do let(:relation) { Project.where('id > ?', Project.maximum(:id) - page.per_page).order(id: :asc) } - it 'indicates this is the last page' do - expect(request).to receive(:apply_headers) do |next_page| - expect(next_page.end_reached?).to be_truthy - end + it 'indicates there is another (likely empty) page' do + lower_bounds = records.last.slice(:id) + expect(page).to receive(:next).with(lower_bounds).and_return(next_page) + expect(request).to receive(:apply_headers).with(next_page) subject end @@ -45,24 +67,10 @@ describe Gitlab::Pagination::Keyset::Pager do let(:relation) { Project.where('id > ?', Project.maximum(:id) + 1).order(id: :asc) } it 'indicates this is the last page' do - expect(request).to receive(:apply_headers) do |next_page| - expect(next_page.end_reached?).to be_truthy - end + expect(request).not_to receive(:apply_headers) subject end end - - it 'returns an array with the loaded records' do - expect(subject).to eq(relation.limit(page.per_page).to_a) - end - - context 'validating the order clause' do - let(:page) { Gitlab::Pagination::Keyset::Page.new(order_by: { created_at: :asc }, per_page: 3) } - - it 'raises an error if has a different order clause than the page' do - expect { subject }.to raise_error(ArgumentError, /order_by does not match/) - end - end end end diff --git a/spec/lib/gitlab/pagination/keyset/request_context_spec.rb b/spec/lib/gitlab/pagination/keyset/request_context_spec.rb index 344ef90efa3e8c956b8b3db86737069b96d7b1f1..6cd5ccc3c196b7e0afbaa3980ad440c4f1028101 100644 --- a/spec/lib/gitlab/pagination/keyset/request_context_spec.rb +++ b/spec/lib/gitlab/pagination/keyset/request_context_spec.rb @@ -53,7 +53,7 @@ describe Gitlab::Pagination::Keyset::RequestContext do let(:request) { double('request', url: "http://#{Gitlab.config.gitlab.host}/api/v4/projects?foo=bar") } let(:params) { { foo: 'bar' } } let(:request_context) { double('request context', params: params, request: request) } - let(:next_page) { double('next page', order_by: { id: :asc }, lower_bounds: { id: 42 }, end_reached?: false) } + let(:next_page) { double('next page', order_by: { id: :asc }, lower_bounds: { id: 42 }) } subject { described_class.new(request_context).apply_headers(next_page) } @@ -92,7 +92,7 @@ describe Gitlab::Pagination::Keyset::RequestContext do end context 'with descending order' do - let(:next_page) { double('next page', order_by: { id: :desc }, lower_bounds: { id: 42 }, end_reached?: false) } + let(:next_page) { double('next page', order_by: { id: :desc }, lower_bounds: { id: 42 }) } it 'sets Links header with a link to the next page' do orig_uri = URI.parse(request_context.request.url) diff --git a/spec/lib/gitlab/pagination/keyset_spec.rb b/spec/lib/gitlab/pagination/keyset_spec.rb index 5c2576d7b4514eb8dd5f5c02fb7a89d11817ae99..bde280c5fcace69e3f4d0df8647d06dcba728ddc 100644 --- a/spec/lib/gitlab/pagination/keyset_spec.rb +++ b/spec/lib/gitlab/pagination/keyset_spec.rb @@ -3,22 +3,6 @@ require 'spec_helper' describe Gitlab::Pagination::Keyset do - describe '.paginate' do - subject { described_class.paginate(request_context, relation) } - - let(:request_context) { double } - let(:relation) { double } - let(:pager) { double } - let(:result) { double } - - it 'uses Pager to paginate the relation' do - expect(Gitlab::Pagination::Keyset::Pager).to receive(:new).with(request_context).and_return(pager) - expect(pager).to receive(:paginate).with(relation).and_return(result) - - expect(subject).to eq(result) - end - end - describe '.available?' do subject { described_class } diff --git a/spec/lib/gitlab/patch/action_dispatch_journey_formatter_spec.rb b/spec/lib/gitlab/patch/action_dispatch_journey_formatter_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..5f0e1f40231682fa302afdd670f358102a3b259d --- /dev/null +++ b/spec/lib/gitlab/patch/action_dispatch_journey_formatter_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::Patch::ActionDispatchJourneyFormatter do + let(:group) { create(:group) } + let(:project) { create(:project, namespace: group) } + let(:pipeline) { create(:ci_empty_pipeline, project: project) } + let(:url) { Gitlab::Routing.url_helpers.project_pipeline_url(project, pipeline) } + let(:expected_path) { "#{project.full_path}/pipelines/#{pipeline.id}" } + + context 'custom implementation of #missing_keys' do + before do + expect_any_instance_of(Gitlab::Patch::ActionDispatchJourneyFormatter).to receive(:missing_keys) + end + + it 'generates correct url' do + expect(url).to end_with(expected_path) + end + end + + context 'original implementation of #missing_keys' do + before do + allow_any_instance_of(Gitlab::Patch::ActionDispatchJourneyFormatter).to receive(:missing_keys) do |instance, route, parts| + instance.send(:old_missing_keys, route, parts) # test the old implementation + end + end + + it 'generates correct url' do + expect(url).to end_with(expected_path) + end + end +end diff --git a/spec/lib/gitlab/phabricator_import/conduit/user_spec.rb b/spec/lib/gitlab/phabricator_import/conduit/user_spec.rb index e88eec2c393de5bd8505b22a51718c8ffeeba4f0..f3928f390bc6cb133518856a9bafab63ed89472a 100644 --- a/spec/lib/gitlab/phabricator_import/conduit/user_spec.rb +++ b/spec/lib/gitlab/phabricator_import/conduit/user_spec.rb @@ -15,13 +15,13 @@ describe Gitlab::PhabricatorImport::Conduit::User do it 'calls the api with the correct params' do expected_params = { - constraints: { phids: ['phid-1', 'phid-2'] } + constraints: { phids: %w[phid-1 phid-2] } } expect(fake_client).to receive(:get).with('user.search', params: expected_params) - user_client.users(['phid-1', 'phid-2']) + user_client.users(%w[phid-1 phid-2]) end it 'returns an array of parsed responses' do @@ -43,7 +43,7 @@ describe Gitlab::PhabricatorImport::Conduit::User do expect(fake_client).to receive(:get).with('user.search', params: second_params).once - user_client.users(['phid-1', 'phid-2']) + user_client.users(%w[phid-1 phid-2]) end end end diff --git a/spec/lib/gitlab/phabricator_import/user_finder_spec.rb b/spec/lib/gitlab/phabricator_import/user_finder_spec.rb index 14a00deeb16e6c2910ce2f6edb9a8d96a1f86e64..f260e38b7c83c739a2e3f0ae068b35f8c0ca7193 100644 --- a/spec/lib/gitlab/phabricator_import/user_finder_spec.rb +++ b/spec/lib/gitlab/phabricator_import/user_finder_spec.rb @@ -5,7 +5,7 @@ require 'spec_helper' describe Gitlab::PhabricatorImport::UserFinder, :clean_gitlab_redis_cache do let(:project) { create(:project, namespace: create(:group)) } - subject(:finder) { described_class.new(project, ['first-phid', 'second-phid']) } + subject(:finder) { described_class.new(project, %w[first-phid second-phid]) } before do project.namespace.add_developer(existing_user) diff --git a/spec/lib/gitlab/profiler_spec.rb b/spec/lib/gitlab/profiler_spec.rb index a19392f4bcba765ae99d9318d4caac95d196f8db..8f6fb6eda653d9d3e89a9b2b577fae98454f50bb 100644 --- a/spec/lib/gitlab/profiler_spec.rb +++ b/spec/lib/gitlab/profiler_spec.rb @@ -84,7 +84,7 @@ describe Gitlab::Profiler do expect(severity).to eq(Logger::DEBUG) expect(message).to include('public').and include(described_class::FILTERED_STRING) expect(message).not_to include(private_token) - end.twice + end.at_least(1) # This spec could be wrapped in more blocks in the future custom_logger.debug("public #{private_token}") end @@ -120,51 +120,6 @@ describe Gitlab::Profiler do end end - describe '.clean_backtrace' do - it 'uses the Rails backtrace cleaner' do - backtrace = [] - - expect(Rails.backtrace_cleaner).to receive(:clean).with(backtrace) - - described_class.clean_backtrace(backtrace) - end - - it 'removes lines from IGNORE_BACKTRACES' do - backtrace = [ - "lib/gitlab/gitaly_client.rb:294:in `block (2 levels) in migrate'", - "lib/gitlab/gitaly_client.rb:331:in `allow_n_plus_1_calls'", - "lib/gitlab/gitaly_client.rb:280:in `block in migrate'", - "lib/gitlab/metrics/influx_db.rb:103:in `measure'", - "lib/gitlab/gitaly_client.rb:278:in `migrate'", - "lib/gitlab/git/repository.rb:1451:in `gitaly_migrate'", - "lib/gitlab/git/commit.rb:66:in `find'", - "app/models/repository.rb:1047:in `find_commit'", - "lib/gitlab/metrics/instrumentation.rb:159:in `block in find_commit'", - "lib/gitlab/metrics/method_call.rb:36:in `measure'", - "lib/gitlab/metrics/instrumentation.rb:159:in `find_commit'", - "app/models/repository.rb:113:in `commit'", - "lib/gitlab/i18n.rb:50:in `with_locale'", - "lib/gitlab/middleware/multipart.rb:95:in `call'", - "lib/gitlab/request_profiler/middleware.rb:14:in `call'", - "ee/lib/gitlab/database/load_balancing/rack_middleware.rb:37:in `call'", - "ee/lib/gitlab/jira/middleware.rb:15:in `call'" - ] - - expect(described_class.clean_backtrace(backtrace)) - .to eq([ - "lib/gitlab/gitaly_client.rb:294:in `block (2 levels) in migrate'", - "lib/gitlab/gitaly_client.rb:331:in `allow_n_plus_1_calls'", - "lib/gitlab/gitaly_client.rb:280:in `block in migrate'", - "lib/gitlab/gitaly_client.rb:278:in `migrate'", - "lib/gitlab/git/repository.rb:1451:in `gitaly_migrate'", - "lib/gitlab/git/commit.rb:66:in `find'", - "app/models/repository.rb:1047:in `find_commit'", - "app/models/repository.rb:113:in `commit'", - "ee/lib/gitlab/jira/middleware.rb:15:in `call'" - ]) - end - end - describe '.with_custom_logger' do context 'when the logger is set' do it 'uses the replacement logger for the duration of the block' do diff --git a/spec/lib/gitlab/project_search_results_spec.rb b/spec/lib/gitlab/project_search_results_spec.rb index 6f4844d454330b2c4df4b06c163f59589dcab5ff..ae4c14e4deb418cd5815a59308a03cd8688f670e 100644 --- a/spec/lib/gitlab/project_search_results_spec.rb +++ b/spec/lib/gitlab/project_search_results_spec.rb @@ -86,8 +86,7 @@ describe Gitlab::ProjectSearchResults do it "loads all blobs for path 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(&:path)).to include(*expected) + results.map(&:data) end it 'finds by content' do diff --git a/spec/services/prometheus/adapter_service_spec.rb b/spec/lib/gitlab/prometheus/adapter_spec.rb similarity index 84% rename from spec/services/prometheus/adapter_service_spec.rb rename to spec/lib/gitlab/prometheus/adapter_spec.rb index 52e035e1f70613df8cd8a8efd990ac6b07bdd381..202bf65f92b0d8ed6b7b889bd3ba233d26b86313 100644 --- a/spec/services/prometheus/adapter_service_spec.rb +++ b/spec/lib/gitlab/prometheus/adapter_spec.rb @@ -2,14 +2,13 @@ require 'spec_helper' -describe Prometheus::AdapterService do - let(:project) { create(:project) } +describe Gitlab::Prometheus::Adapter do + let_it_be(:project) { create(:project) } + let_it_be(:cluster, reload: true) { create(:cluster, :provided_by_user, environment_scope: '*', projects: [project]) } - subject { described_class.new(project) } + subject { described_class.new(project, cluster) } describe '#prometheus_adapter' do - let(:cluster) { create(:cluster, :provided_by_user, environment_scope: '*', projects: [project]) } - context 'prometheus service can execute queries' do let(:prometheus_service) { double(:prometheus_service, can_query?: true) } diff --git a/spec/lib/gitlab/prometheus/queries/additional_metrics_deployment_query_spec.rb b/spec/lib/gitlab/prometheus/queries/additional_metrics_deployment_query_spec.rb index 4bdc57c8c045d3821f8753341bbec8b74c2c4776..15edc649702b94f133fc6474b65568cb5bfe8c71 100644 --- a/spec/lib/gitlab/prometheus/queries/additional_metrics_deployment_query_spec.rb +++ b/spec/lib/gitlab/prometheus/queries/additional_metrics_deployment_query_spec.rb @@ -8,7 +8,8 @@ describe Gitlab::Prometheus::Queries::AdditionalMetricsDeploymentQuery do end include_examples 'additional metrics query' do - let(:deployment) { create(:deployment, environment: environment) } + let(:project) { create(:project, :repository) } + let(:deployment) { create(:deployment, environment: environment, project: project) } let(:query_params) { [deployment.id] } it 'queries using specific time' do diff --git a/spec/lib/gitlab/quick_actions/dsl_spec.rb b/spec/lib/gitlab/quick_actions/dsl_spec.rb index c98c36622f5bdfc0670bdfe03d42d7a166cee5d4..1145a7edc85c32fd3e9093d8414961d06125a5ad 100644 --- a/spec/lib/gitlab/quick_actions/dsl_spec.rb +++ b/spec/lib/gitlab/quick_actions/dsl_spec.rb @@ -5,7 +5,7 @@ require 'spec_helper' describe Gitlab::QuickActions::Dsl do before :all do DummyClass = Struct.new(:project) do - include Gitlab::QuickActions::Dsl # rubocop:disable RSpec/DescribedClass + include Gitlab::QuickActions::Dsl desc 'A command with no args' command :no_args, :none do diff --git a/spec/lib/gitlab/repository_cache_spec.rb b/spec/lib/gitlab/repository_cache_spec.rb index 6a684595eb8294729cb5f2e2ed42f0e7f53b0e82..1b7dd1766da17d97fbe2a99445ae01a34ce3b832 100644 --- a/spec/lib/gitlab/repository_cache_spec.rb +++ b/spec/lib/gitlab/repository_cache_spec.rb @@ -3,8 +3,8 @@ require 'spec_helper' describe Gitlab::RepositoryCache do + let_it_be(:project) { create(:project) } let(:backend) { double('backend').as_null_object } - let(:project) { create(:project) } let(:repository) { project.repository } let(:namespace) { "#{repository.full_path}:#{project.id}" } let(:cache) { described_class.new(repository, backend: backend) } diff --git a/spec/lib/gitlab/repository_set_cache_spec.rb b/spec/lib/gitlab/repository_set_cache_spec.rb index 87e51f801e5d42d7508d7340b4cb568b542ddddc..de0f36023468ab724a5a32b87fc5f15736ad3c65 100644 --- a/spec/lib/gitlab/repository_set_cache_spec.rb +++ b/spec/lib/gitlab/repository_set_cache_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' describe Gitlab::RepositorySetCache, :clean_gitlab_redis_cache do - let(:project) { create(:project) } + let_it_be(:project) { create(:project) } let(:repository) { project.repository } let(:namespace) { "#{repository.full_path}:#{project.id}" } let(:cache) { described_class.new(repository) } diff --git a/spec/lib/gitlab/request_context_spec.rb b/spec/lib/gitlab/request_context_spec.rb index 87b8029de2e935b77de624ac37f77b2899fbdb66..5785dbfd850a3424cde0b60bafef3b6196aa6cbf 100644 --- a/spec/lib/gitlab/request_context_spec.rb +++ b/spec/lib/gitlab/request_context_spec.rb @@ -2,59 +2,58 @@ require 'spec_helper' -describe Gitlab::RequestContext do - describe '#client_ip' do - subject { described_class.client_ip } +describe Gitlab::RequestContext, :request_store do + subject { described_class.instance } - let(:app) { -> (env) {} } - let(:env) { Hash.new } + it { is_expected.to have_attributes(client_ip: nil, start_thread_cpu_time: nil, request_start_time: nil) } - context 'with X-Forwarded-For headers', :request_store do - let(:load_balancer_ip) { '1.2.3.4' } - let(:headers) do - { - 'HTTP_X_FORWARDED_FOR' => "#{load_balancer_ip}, 127.0.0.1", - 'REMOTE_ADDR' => '127.0.0.1' - } - end + describe '#request_deadline' do + let(:request_start_time) { 1575982156.206008 } - let(:env) { Rack::MockRequest.env_for("/").merge(headers) } + before do + allow(subject).to receive(:request_start_time).and_return(request_start_time) + end + + it "sets the time to #{Settings.gitlab.max_request_duration_seconds} seconds in the future" do + expect(subject.request_deadline).to eq(request_start_time + Settings.gitlab.max_request_duration_seconds) + expect(subject.request_deadline).to be_a(Float) + end + + it 'returns nil if there is no start time' do + allow(subject).to receive(:request_start_time).and_return(nil) + + expect(subject.request_deadline).to be_nil + end - it 'returns the load balancer IP' do - client_ip = nil + it 'only checks the feature once per request-instance' do + expect(Feature).to receive(:enabled?).with(:request_deadline).once - endpoint = proc do - client_ip = Gitlab::SafeRequestStore[:client_ip] - [200, {}, ["Hello"]] - end + 2.times { subject.request_deadline } + end - described_class.new(endpoint).call(env) + it 'returns nil when the feature is disabled' do + stub_feature_flags(request_deadline: false) - expect(client_ip).to eq(load_balancer_ip) - end + expect(subject.request_deadline).to be_nil end + end - context 'when RequestStore::Middleware is used' do - around do |example| - RequestStore::Middleware.new(-> (env) { example.run }).call({}) - end + describe '#ensure_request_deadline_not_exceeded!' do + it 'does not raise an error when there was no deadline' do + expect(subject).to receive(:request_deadline).and_return(nil) + expect { subject.ensure_deadline_not_exceeded! }.not_to raise_error + end - context 'request' do - let(:ip) { '192.168.1.11' } + it 'does not raise an error if the deadline is in the future' do + allow(subject).to receive(:request_deadline).and_return(Gitlab::Metrics::System.real_time + 10) - before do - allow_next_instance_of(Rack::Request) do |instance| - allow(instance).to receive(:ip).and_return(ip) - end - described_class.new(app).call(env) - end + expect { subject.ensure_deadline_not_exceeded! }.not_to raise_error + end - it { is_expected.to eq(ip) } - end + it 'raises an error when the deadline is in the past' do + allow(subject).to receive(:request_deadline).and_return(Gitlab::Metrics::System.real_time - 10) - context 'before RequestContext middleware run' do - it { is_expected.to be_nil } - end + expect { subject.ensure_deadline_not_exceeded! }.to raise_error(described_class::RequestDeadlineExceeded) end end end diff --git a/spec/lib/gitlab/runtime_spec.rb b/spec/lib/gitlab/runtime_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..194ed49bb32e28c07fcd463322db760a3e0c84a7 --- /dev/null +++ b/spec/lib/gitlab/runtime_spec.rb @@ -0,0 +1,163 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::Runtime do + before do + allow(described_class).to receive(:process_name).and_return('ruby') + end + + context "when unknown" do + it "raises an exception when trying to identify" do + expect { subject.identify }.to raise_error(subject::UnknownProcessError) + end + end + + context "on multiple matches" do + before do + stub_const('::Puma', double) + stub_const('::Rails::Console', double) + end + + it "raises an exception when trying to identify" do + expect { subject.identify }.to raise_error(subject::AmbiguousProcessError) + end + end + + context "puma" do + let(:puma_type) { double('::Puma') } + let(:options) do + { + max_threads: 2 + } + end + + before do + stub_const('::Puma', puma_type) + allow(puma_type).to receive_message_chain(:cli_config, :options).and_return(options) + end + + it "identifies itself" do + expect(subject.identify).to eq(:puma) + expect(subject.puma?).to be(true) + end + + it "does not identify as others" do + expect(subject.unicorn?).to be(false) + expect(subject.sidekiq?).to be(false) + expect(subject.console?).to be(false) + expect(subject.rake?).to be(false) + expect(subject.rspec?).to be(false) + end + + it "reports its maximum concurrency" do + expect(subject.max_threads).to eq(2) + end + end + + context "unicorn" do + let(:unicorn_type) { Module.new } + let(:unicorn_server_type) { Class.new } + + before do + stub_const('::Unicorn', unicorn_type) + stub_const('::Unicorn::HttpServer', unicorn_server_type) + end + + it "identifies itself" do + expect(subject.identify).to eq(:unicorn) + expect(subject.unicorn?).to be(true) + end + + it "does not identify as others" do + expect(subject.puma?).to be(false) + expect(subject.sidekiq?).to be(false) + expect(subject.console?).to be(false) + expect(subject.rake?).to be(false) + expect(subject.rspec?).to be(false) + end + + it "reports its maximum concurrency" do + expect(subject.max_threads).to eq(1) + end + end + + context "sidekiq" do + let(:sidekiq_type) { double('::Sidekiq') } + let(:options) do + { + concurrency: 2 + } + end + + before do + stub_const('::Sidekiq', sidekiq_type) + allow(sidekiq_type).to receive(:server?).and_return(true) + allow(sidekiq_type).to receive(:options).and_return(options) + end + + it "identifies itself" do + expect(subject.identify).to eq(:sidekiq) + expect(subject.sidekiq?).to be(true) + end + + it "does not identify as others" do + expect(subject.unicorn?).to be(false) + expect(subject.puma?).to be(false) + expect(subject.console?).to be(false) + expect(subject.rake?).to be(false) + expect(subject.rspec?).to be(false) + end + + it "reports its maximum concurrency" do + expect(subject.max_threads).to eq(2) + end + end + + context "console" do + let(:console_type) { double('::Rails::Console') } + + before do + stub_const('::Rails::Console', console_type) + end + + it "identifies itself" do + expect(subject.identify).to eq(:console) + expect(subject.console?).to be(true) + end + + it "does not identify as others" do + expect(subject.unicorn?).to be(false) + expect(subject.sidekiq?).to be(false) + expect(subject.puma?).to be(false) + expect(subject.rake?).to be(false) + expect(subject.rspec?).to be(false) + end + + it "reports its maximum concurrency" do + expect(subject.max_threads).to eq(1) + end + end + + context "rspec" do + before do + allow(described_class).to receive(:process_name).and_return('rspec') + end + + it "identifies itself" do + expect(subject.identify).to eq(:rspec) + expect(subject.rspec?).to be(true) + end + + it "does not identify as others" do + expect(subject.unicorn?).to be(false) + expect(subject.sidekiq?).to be(false) + expect(subject.rake?).to be(false) + expect(subject.puma?).to be(false) + end + + it "reports its maximum concurrency" do + expect(subject.max_threads).to eq(1) + end + end +end diff --git a/spec/lib/gitlab/sidekiq_logging/exception_handler_spec.rb b/spec/lib/gitlab/sidekiq_logging/exception_handler_spec.rb index 24b6090cb195f3799e1e51fc9b634ca67147ba71..a79a0678e2bb232d2269138e9411fbe36c94aafa 100644 --- a/spec/lib/gitlab/sidekiq_logging/exception_handler_spec.rb +++ b/spec/lib/gitlab/sidekiq_logging/exception_handler_spec.rb @@ -33,7 +33,7 @@ describe Gitlab::SidekiqLogging::ExceptionHandler do error_class: 'RuntimeError', error_message: exception_message, context: 'Test', - error_backtrace: Gitlab::Profiler.clean_backtrace(backtrace) + error_backtrace: Gitlab::BacktraceCleaner.clean_backtrace(backtrace) ) expect(logger).to receive(:warn).with(expected_data) diff --git a/spec/lib/gitlab/sidekiq_logging/json_formatter_spec.rb b/spec/lib/gitlab/sidekiq_logging/json_formatter_spec.rb index a2cb38ec5b1a91f13151b52a191c69c885a1035b..f2092334117c80ff24a801a91add70a2c3c7be1e 100644 --- a/spec/lib/gitlab/sidekiq_logging/json_formatter_spec.rb +++ b/spec/lib/gitlab/sidekiq_logging/json_formatter_spec.rb @@ -3,28 +3,53 @@ require 'spec_helper' describe Gitlab::SidekiqLogging::JSONFormatter do - let(:hash_input) { { foo: 1, bar: 'test' } } let(:message) { 'This is a test' } - let(:timestamp) { Time.now } - - it 'wraps a Hash' do - result = subject.call('INFO', timestamp, 'my program', hash_input) - - data = JSON.parse(result) - expected_output = hash_input.stringify_keys - expected_output['severity'] = 'INFO' - expected_output['time'] = timestamp.utc.iso8601(3) - - expect(data).to eq(expected_output) + let(:now) { Time.now } + let(:timestamp) { now.utc.to_f } + let(:timestamp_iso8601) { now.iso8601(3) } + + describe 'with a Hash' do + let(:hash_input) do + { + foo: 1, + 'bar' => 'test', + 'created_at' => timestamp, + 'enqueued_at' => timestamp, + 'started_at' => timestamp, + 'retried_at' => timestamp, + 'failed_at' => timestamp, + 'completed_at' => timestamp_iso8601 + } + end + + it 'properly formats timestamps into ISO 8601 form' do + result = subject.call('INFO', now, 'my program', hash_input) + + data = JSON.parse(result) + expected_output = hash_input.stringify_keys.merge!( + { + 'severity' => 'INFO', + 'time' => timestamp_iso8601, + 'created_at' => timestamp_iso8601, + 'enqueued_at' => timestamp_iso8601, + 'started_at' => timestamp_iso8601, + 'retried_at' => timestamp_iso8601, + 'failed_at' => timestamp_iso8601, + 'completed_at' => timestamp_iso8601 + } + ) + + expect(data).to eq(expected_output) + end end it 'wraps a String' do - result = subject.call('DEBUG', timestamp, 'my string', message) + result = subject.call('DEBUG', now, 'my string', message) data = JSON.parse(result) expected_output = { severity: 'DEBUG', - time: timestamp.utc.iso8601(3), + time: timestamp_iso8601, message: message } diff --git a/spec/lib/gitlab/sidekiq_logging/structured_logger_spec.rb b/spec/lib/gitlab/sidekiq_logging/structured_logger_spec.rb index cb870cc996b11dc7719d56bc5585090607df93ef..43cdb998091fb910a5744b62f8b8840365068875 100644 --- a/spec/lib/gitlab/sidekiq_logging/structured_logger_spec.rb +++ b/spec/lib/gitlab/sidekiq_logging/structured_logger_spec.rb @@ -1,10 +1,10 @@ # frozen_string_literal: true -require 'fast_spec_helper' +require 'spec_helper' describe Gitlab::SidekiqLogging::StructuredLogger do describe '#call' do - let(:timestamp) { Time.iso8601('2018-01-01T12:00:00Z') } + let(:timestamp) { Time.iso8601('2018-01-01T12:00:00.000Z') } let(:created_at) { timestamp - 1.second } let(:scheduling_latency_s) { 1.0 } @@ -30,8 +30,8 @@ describe Gitlab::SidekiqLogging::StructuredLogger do 'message' => 'TestWorker JID-da883554ee4fe414012f5f42: start', 'job_status' => 'start', 'pid' => Process.pid, - 'created_at' => created_at.iso8601(6), - 'enqueued_at' => created_at.iso8601(6), + 'created_at' => created_at.to_f, + 'enqueued_at' => created_at.to_f, 'scheduling_latency_s' => scheduling_latency_s ) end @@ -40,8 +40,10 @@ describe Gitlab::SidekiqLogging::StructuredLogger do 'message' => 'TestWorker JID-da883554ee4fe414012f5f42: done: 0.0 sec', 'job_status' => 'done', 'duration' => 0.0, - "completed_at" => timestamp.iso8601(6), - "cpu_s" => 1.111112 + 'completed_at' => timestamp.to_f, + 'cpu_s' => 1.111112, + 'db_duration' => 0, + 'db_duration_s' => 0 ) end let(:exception_payload) do @@ -145,7 +147,7 @@ describe Gitlab::SidekiqLogging::StructuredLogger do end context 'with latency' do - let(:created_at) { Time.iso8601('2018-01-01T10:00:00Z') } + let(:created_at) { Time.iso8601('2018-01-01T10:00:00.000Z') } let(:scheduling_latency_s) { 7200.0 } it 'logs with scheduling latency' do @@ -183,22 +185,59 @@ describe Gitlab::SidekiqLogging::StructuredLogger do end end end + + context 'when the job performs database queries' do + before do + allow(Time).to receive(:now).and_return(timestamp) + allow(Process).to receive(:clock_gettime).and_call_original + end + + let(:expected_start_payload) { start_payload.except('args') } + + let(:expected_end_payload) do + end_payload.except('args').merge('cpu_s' => a_value > 0) + end + + let(:expected_end_payload_with_db) do + expected_end_payload.merge( + 'db_duration' => a_value >= 100, + 'db_duration_s' => a_value >= 0.1 + ) + end + + it 'logs the database time' do + expect(logger).to receive(:info).with(expected_start_payload).ordered + expect(logger).to receive(:info).with(expected_end_payload_with_db).ordered + + subject.call(job, 'test_queue') { ActiveRecord::Base.connection.execute('SELECT pg_sleep(0.1);') } + end + + it 'prevents database time from leaking to the next job' do + expect(logger).to receive(:info).with(expected_start_payload).ordered + expect(logger).to receive(:info).with(expected_end_payload_with_db).ordered + expect(logger).to receive(:info).with(expected_start_payload).ordered + expect(logger).to receive(:info).with(expected_end_payload).ordered + + subject.call(job, 'test_queue') { ActiveRecord::Base.connection.execute('SELECT pg_sleep(0.1);') } + subject.call(job, 'test_queue') { } + end + end end describe '#add_time_keys!' do let(:time) { { duration: 0.1231234, cputime: 1.2342345 } } let(:payload) { { 'class' => 'my-class', 'message' => 'my-message', 'job_status' => 'my-job-status' } } - let(:current_utc_time) { '2019-09-23 10:00:58 UTC' } - let(:payload_with_time_keys) { { 'class' => 'my-class', 'message' => 'my-message', 'job_status' => 'my-job-status', 'duration' => 0.123123, 'cpu_s' => 1.234235, 'completed_at' => current_utc_time } } + let(:current_utc_time) { Time.now.utc } + let(:payload_with_time_keys) { { 'class' => 'my-class', 'message' => 'my-message', 'job_status' => 'my-job-status', 'duration' => 0.123123, 'cpu_s' => 1.234235, 'completed_at' => current_utc_time.to_f } } subject { described_class.new } it 'update payload correctly' do - expect(Time).to receive_message_chain(:now, :utc).and_return(current_utc_time) + Timecop.freeze(current_utc_time) do + subject.send(:add_time_keys!, time, payload) - subject.send(:add_time_keys!, time, payload) - - expect(payload).to eq(payload_with_time_keys) + expect(payload).to eq(payload_with_time_keys) + end end end end diff --git a/spec/lib/gitlab/sidekiq_middleware/client_metrics_spec.rb b/spec/lib/gitlab/sidekiq_middleware/client_metrics_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..6516016e67f328c7a36aa9106940ecd5307c2c95 --- /dev/null +++ b/spec/lib/gitlab/sidekiq_middleware/client_metrics_spec.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::SidekiqMiddleware::ClientMetrics do + context "with worker attribution" do + subject { described_class.new } + + let(:queue) { :test } + let(:worker_class) { worker.class } + let(:job) { {} } + let(:default_labels) { { queue: queue.to_s, boundary: "", external_dependencies: "no", feature_category: "", latency_sensitive: "no" } } + + shared_examples "a metrics client middleware" do + context "with mocked prometheus" do + let(:enqueued_jobs_metric) { double('enqueued jobs metric', increment: true) } + + before do + allow(Gitlab::Metrics).to receive(:counter).with(described_class::ENQUEUED, anything).and_return(enqueued_jobs_metric) + end + + describe '#call' do + it 'yields block' do + expect { |b| subject.call(worker, job, :test, double, &b) }.to yield_control.once + end + + it 'increments enqueued jobs metric' do + expect(enqueued_jobs_metric).to receive(:increment).with(labels, 1) + + subject.call(worker, job, :test, double) { nil } + end + end + end + end + + context "when workers are not attributed" do + class TestNonAttributedWorker + include Sidekiq::Worker + end + + it_behaves_like "a metrics client middleware" do + let(:worker) { TestNonAttributedWorker.new } + let(:labels) { default_labels } + end + end + + context "when workers are attributed" do + def create_attributed_worker_class(latency_sensitive, external_dependencies, resource_boundary, category) + Class.new do + include Sidekiq::Worker + include WorkerAttributes + + latency_sensitive_worker! if latency_sensitive + worker_has_external_dependencies! if external_dependencies + worker_resource_boundary resource_boundary unless resource_boundary == :unknown + feature_category category unless category.nil? + end + end + + let(:latency_sensitive) { false } + let(:external_dependencies) { false } + let(:resource_boundary) { :unknown } + let(:feature_category) { nil } + let(:worker_class) { create_attributed_worker_class(latency_sensitive, external_dependencies, resource_boundary, feature_category) } + let(:worker) { worker_class.new } + + context "latency sensitive" do + it_behaves_like "a metrics client middleware" do + let(:latency_sensitive) { true } + let(:labels) { default_labels.merge(latency_sensitive: "yes") } + end + end + + context "external dependencies" do + it_behaves_like "a metrics client middleware" do + let(:external_dependencies) { true } + let(:labels) { default_labels.merge(external_dependencies: "yes") } + end + end + + context "cpu boundary" do + it_behaves_like "a metrics client middleware" do + let(:resource_boundary) { :cpu } + let(:labels) { default_labels.merge(boundary: "cpu") } + end + end + + context "memory boundary" do + it_behaves_like "a metrics client middleware" do + let(:resource_boundary) { :memory } + let(:labels) { default_labels.merge(boundary: "memory") } + end + end + + context "feature category" do + it_behaves_like "a metrics client middleware" do + let(:feature_category) { :authentication } + let(:labels) { default_labels.merge(feature_category: "authentication") } + end + end + + context "combined" do + it_behaves_like "a metrics client middleware" do + let(:latency_sensitive) { true } + let(:external_dependencies) { true } + let(:resource_boundary) { :cpu } + let(:feature_category) { :authentication } + let(:labels) { default_labels.merge(latency_sensitive: "yes", external_dependencies: "yes", boundary: "cpu", feature_category: "authentication") } + end + end + end + end +end diff --git a/spec/lib/gitlab/sidekiq_middleware/correlation_injector_spec.rb b/spec/lib/gitlab/sidekiq_middleware/correlation_injector_spec.rb deleted file mode 100644 index d5ed939485a48952f0da0635d9562817dc8b2b8c..0000000000000000000000000000000000000000 --- a/spec/lib/gitlab/sidekiq_middleware/correlation_injector_spec.rb +++ /dev/null @@ -1,49 +0,0 @@ -# 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_next_instance_of(described_class) do |instance| - expect(instance).to receive(:call).and_call_original - end - - Labkit::Correlation::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 deleted file mode 100644 index 27eea96340238c2591b978453836a23fadf41fb4..0000000000000000000000000000000000000000 --- a/spec/lib/gitlab/sidekiq_middleware/correlation_logger_spec.rb +++ /dev/null @@ -1,35 +0,0 @@ -# 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', :sidekiq_might_not_need_inline 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(Labkit::Correlation::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/sidekiq_middleware/metrics_spec.rb b/spec/lib/gitlab/sidekiq_middleware/server_metrics_spec.rb similarity index 99% rename from spec/lib/gitlab/sidekiq_middleware/metrics_spec.rb rename to spec/lib/gitlab/sidekiq_middleware/server_metrics_spec.rb index 36c6f377bde19c137eecaff606c7be99dc48f6b7..65a961b34f89b266d7de62b4ee2072fc628e02f9 100644 --- a/spec/lib/gitlab/sidekiq_middleware/metrics_spec.rb +++ b/spec/lib/gitlab/sidekiq_middleware/server_metrics_spec.rb @@ -1,8 +1,8 @@ # frozen_string_literal: true -require 'fast_spec_helper' +require 'spec_helper' -describe Gitlab::SidekiqMiddleware::Metrics do +describe Gitlab::SidekiqMiddleware::ServerMetrics do context "with worker attribution" do subject { described_class.new } diff --git a/spec/lib/gitlab/sidekiq_middleware_spec.rb b/spec/lib/gitlab/sidekiq_middleware_spec.rb index aef472e0648c172ed00f81e50f0e9b893ad6e561..473d85c0143dea77590faf13d677e6d34e148a11 100644 --- a/spec/lib/gitlab/sidekiq_middleware_spec.rb +++ b/spec/lib/gitlab/sidekiq_middleware_spec.rb @@ -38,10 +38,10 @@ describe Gitlab::SidekiqMiddleware do [ Gitlab::SidekiqMiddleware::Monitor, Gitlab::SidekiqMiddleware::BatchLoader, - Gitlab::SidekiqMiddleware::CorrelationLogger, + Labkit::Middleware::Sidekiq::Server, Gitlab::SidekiqMiddleware::InstrumentationLogger, Gitlab::SidekiqStatus::ServerMiddleware, - Gitlab::SidekiqMiddleware::Metrics, + Gitlab::SidekiqMiddleware::ServerMetrics, Gitlab::SidekiqMiddleware::ArgumentsLogger, Gitlab::SidekiqMiddleware::MemoryKiller, Gitlab::SidekiqMiddleware::RequestStoreMiddleware @@ -74,7 +74,7 @@ describe Gitlab::SidekiqMiddleware do let(:request_store) { false } let(:disabled_sidekiq_middlewares) do [ - Gitlab::SidekiqMiddleware::Metrics, + Gitlab::SidekiqMiddleware::ServerMetrics, Gitlab::SidekiqMiddleware::ArgumentsLogger, Gitlab::SidekiqMiddleware::MemoryKiller, Gitlab::SidekiqMiddleware::RequestStoreMiddleware @@ -120,7 +120,7 @@ describe Gitlab::SidekiqMiddleware do # This test ensures that this does not happen it "invokes the chain" do expect_any_instance_of(Gitlab::SidekiqStatus::ClientMiddleware).to receive(:call).with(*middleware_expected_args).once.and_call_original - expect_any_instance_of(Gitlab::SidekiqMiddleware::CorrelationInjector).to receive(:call).with(*middleware_expected_args).once.and_call_original + expect_any_instance_of(Labkit::Middleware::Sidekiq::Client).to receive(:call).with(*middleware_expected_args).once.and_call_original expect { |b| chain.invoke(worker_class_arg, job, queue, redis_pool, &b) }.to yield_control.once end diff --git a/spec/lib/gitlab/slash_commands/command_spec.rb b/spec/lib/gitlab/slash_commands/command_spec.rb index 73b93589fac610603eef61368baf817f8b0a799a..9849cf78b2f32f569908807d51ee70409f600e50 100644 --- a/spec/lib/gitlab/slash_commands/command_spec.rb +++ b/spec/lib/gitlab/slash_commands/command_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' describe Gitlab::SlashCommands::Command do - let(:project) { create(:project) } + let(:project) { create(:project, :repository) } let(:user) { create(:user) } let(:chat_name) { double(:chat_name, user: user) } diff --git a/spec/lib/gitlab/slash_commands/deploy_spec.rb b/spec/lib/gitlab/slash_commands/deploy_spec.rb index 93a724d8e12d70458ebc3edf5de90c4dd8e0f090..fb9969800a2f77e0962f55e762ca7184d660f95f 100644 --- a/spec/lib/gitlab/slash_commands/deploy_spec.rb +++ b/spec/lib/gitlab/slash_commands/deploy_spec.rb @@ -4,7 +4,7 @@ require 'spec_helper' describe Gitlab::SlashCommands::Deploy do describe '#execute' do - let(:project) { create(:project) } + let(:project) { create(:project, :repository) } let(:user) { create(:user) } let(:chat_name) { double(:chat_name, user: user) } let(:regex_match) { described_class.match('deploy staging to production') } diff --git a/spec/lib/gitlab/usage_data_spec.rb b/spec/lib/gitlab/usage_data_spec.rb index 6ab23b00d5c74d31cb08044657a4990017c012be..cf1dacd088e45c0f9beb76c8be6c2fae20c6dfbf 100644 --- a/spec/lib/gitlab/usage_data_spec.rb +++ b/spec/lib/gitlab/usage_data_spec.rb @@ -17,8 +17,8 @@ describe Gitlab::UsageData do create(:service, project: projects[0], type: 'SlackSlashCommandsService', active: true) create(:service, project: projects[1], type: 'SlackService', active: true) create(:service, project: projects[2], type: 'SlackService', active: true) - create(:service, project: projects[2], type: 'MattermostService', active: true) - create(:service, project: projects[2], type: 'JenkinsService', active: true) + create(:service, project: projects[2], type: 'MattermostService', active: false) + create(:service, project: projects[2], type: 'MattermostService', active: true, template: true) create(:service, project: projects[2], type: 'CustomIssueTrackerService', active: true) create(:project_error_tracking_setting, project: projects[0]) create(:project_error_tracking_setting, project: projects[1], enabled: false) @@ -168,13 +168,15 @@ describe Gitlab::UsageData do pool_repositories projects projects_imported_from_github + projects_asana_active projects_jira_active projects_jira_server_active projects_jira_cloud_active projects_slack_notifications_active projects_slack_slash_active + projects_slack_active + projects_slack_slash_commands_active projects_custom_issue_tracker_active - projects_jenkins_active projects_mattermost_active projects_prometheus_active projects_with_repositories_enabled @@ -203,15 +205,17 @@ describe Gitlab::UsageData do count_data = subject[:counts] expect(count_data[:projects]).to eq(4) + expect(count_data[:projects_asana_active]).to eq(0) expect(count_data[:projects_prometheus_active]).to eq(1) expect(count_data[:projects_jira_active]).to eq(4) expect(count_data[:projects_jira_server_active]).to eq(2) expect(count_data[:projects_jira_cloud_active]).to eq(2) expect(count_data[:projects_slack_notifications_active]).to eq(2) expect(count_data[:projects_slack_slash_active]).to eq(1) + expect(count_data[:projects_slack_active]).to eq(2) + expect(count_data[:projects_slack_slash_commands_active]).to eq(1) expect(count_data[:projects_custom_issue_tracker_active]).to eq(1) - expect(count_data[:projects_jenkins_active]).to eq(1) - expect(count_data[:projects_mattermost_active]).to eq(1) + expect(count_data[:projects_mattermost_active]).to eq(0) expect(count_data[:projects_with_repositories_enabled]).to eq(3) expect(count_data[:projects_with_error_tracking_enabled]).to eq(1) expect(count_data[:issues_created_from_gitlab_error_tracking_ui]).to eq(1) @@ -339,12 +343,6 @@ describe Gitlab::UsageData do expect(described_class.count(relation)).to eq(1) end - it 'returns the count for count_by when provided' do - allow(relation).to receive(:count).with(:creator_id).and_return(2) - - expect(described_class.count(relation, count_by: :creator_id)).to eq(2) - end - it 'returns the fallback value when counting fails' do allow(relation).to receive(:count).and_raise(ActiveRecord::StatementInvalid.new('')) diff --git a/spec/lib/gitlab/utils/lazy_attributes_spec.rb b/spec/lib/gitlab/utils/lazy_attributes_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..c0005c194c4c3cc6522cb6fbdb68b1724d614927 --- /dev/null +++ b/spec/lib/gitlab/utils/lazy_attributes_spec.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true +require 'fast_spec_helper' +require 'active_support/concern' + +describe Gitlab::Utils::LazyAttributes do + subject(:klass) do + Class.new do + include Gitlab::Utils::LazyAttributes + + lazy_attr_reader :number, type: Numeric + lazy_attr_reader :reader_1, :reader_2 + lazy_attr_accessor :incorrect_type, :string_attribute, :accessor_2, type: String + + def initialize + @number = -> { 1 } + @reader_1, @reader_2 = 'reader_1', -> { 'reader_2' } + @incorrect_type, @accessor_2 = -> { :incorrect_type }, -> { 'accessor_2' } + end + end + end + + context 'class methods' do + it { is_expected.to respond_to(:lazy_attr_reader, :lazy_attr_accessor) } + it { is_expected.not_to respond_to(:define_lazy_reader, :define_lazy_writer) } + end + + context 'instance methods' do + subject(:instance) { klass.new } + + it do + is_expected.to respond_to(:number, :reader_1, :reader_2, :incorrect_type, + :incorrect_type=, :accessor_2, :accessor_2=, + :string_attribute, :string_attribute=) + end + + context 'reading attributes' do + it 'returns the correct values for procs', :aggregate_failures do + expect(instance.number).to eq(1) + expect(instance.reader_2).to eq('reader_2') + expect(instance.accessor_2).to eq('accessor_2') + end + + it 'does not return the value if the type did not match what was specified' do + expect(instance.incorrect_type).to be_nil + end + + it 'only calls the block once even if it returned `nil`', :aggregate_failures do + expect(instance.instance_variable_get('@number')).to receive(:call).once.and_call_original + expect(instance.instance_variable_get('@accessor_2')).to receive(:call).once.and_call_original + expect(instance.instance_variable_get('@incorrect_type')).to receive(:call).once.and_call_original + + 2.times do + instance.number + instance.incorrect_type + instance.accessor_2 + end + end + end + + context 'writing attributes' do + it 'sets the correct values', :aggregate_failures do + instance.string_attribute = -> { 'updated 1' } + instance.accessor_2 = nil + + expect(instance.string_attribute).to eq('updated 1') + expect(instance.accessor_2).to be_nil + end + end + end +end diff --git a/spec/lib/gitlab/utils_spec.rb b/spec/lib/gitlab/utils_spec.rb index 890918d4a7c6f3bc769cabc7e87049f844eb8e9e..85a536ee6ad0b566d2132f349f18a2d9b3900504 100644 --- a/spec/lib/gitlab/utils_spec.rb +++ b/spec/lib/gitlab/utils_spec.rb @@ -3,8 +3,9 @@ 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, :check_path_traversal!, to: :described_class + delegate :to_boolean, :boolean_to_yes_no, :slugify, :random_string, :which, + :ensure_array_from_string, :to_exclusive_sentence, :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 @@ -46,6 +47,36 @@ describe Gitlab::Utils do end end + describe '.to_exclusive_sentence' do + it 'calls #to_sentence on the array' do + array = double + + expect(array).to receive(:to_sentence) + + to_exclusive_sentence(array) + end + + it 'joins arrays with two elements correctly' do + array = %w(foo bar) + + expect(to_exclusive_sentence(array)).to eq('foo or bar') + end + + it 'joins arrays with more than two elements correctly' do + array = %w(foo bar baz) + + expect(to_exclusive_sentence(array)).to eq('foo, bar, or baz') + end + + it 'localizes the connector words' do + array = %w(foo bar baz) + + expect(described_class).to receive(:_).with(' or ').and_return(' <1> ') + expect(described_class).to receive(:_).with(', or ').and_return(', <2> ') + expect(to_exclusive_sentence(array)).to eq('foo, bar, <2> baz') + end + end + describe '.nlbr' do it 'replaces new lines with <br>' do expect(described_class.nlbr("<b>hello</b>\n<i>world</i>".freeze)).to eq("hello<br>world") diff --git a/spec/lib/prometheus/pid_provider_spec.rb b/spec/lib/prometheus/pid_provider_spec.rb index 6fdc11b14c40be76290ae7e862fa3fbd90e7e361..5a17f25f144647942d488f270eb0b7a41b221164 100644 --- a/spec/lib/prometheus/pid_provider_spec.rb +++ b/spec/lib/prometheus/pid_provider_spec.rb @@ -6,16 +6,13 @@ describe Prometheus::PidProvider do describe '.worker_id' do subject { described_class.worker_id } - let(:sidekiq_module) { Module.new } - before do - allow(sidekiq_module).to receive(:server?).and_return(false) - stub_const('Sidekiq', sidekiq_module) + allow(Gitlab::Runtime).to receive(:sidekiq?).and_return(false) end context 'when running in Sidekiq server mode' do before do - expect(Sidekiq).to receive(:server?).and_return(true) + allow(Gitlab::Runtime).to receive(:sidekiq?).and_return(true) end context 'in a clustered setup' do @@ -33,8 +30,7 @@ describe Prometheus::PidProvider do context 'when running in Unicorn mode' do before do - stub_const('Unicorn::Worker', Class.new) - hide_const('Puma') + allow(Gitlab::Runtime).to receive(:unicorn?).and_return(true) expect(described_class).to receive(:process_name) .at_least(:once) @@ -94,8 +90,7 @@ describe Prometheus::PidProvider do context 'when running in Puma mode' do before do - stub_const('Puma', Module.new) - hide_const('Unicorn::Worker') + allow(Gitlab::Runtime).to receive(:puma?).and_return(true) expect(described_class).to receive(:process_name) .at_least(:once) @@ -116,11 +111,6 @@ describe Prometheus::PidProvider do end context 'when running in unknown mode' do - before do - hide_const('Puma') - hide_const('Unicorn::Worker') - end - it { is_expected.to eq "process_#{Process.pid}" } end end diff --git a/spec/lib/quality/helm_client_spec.rb b/spec/lib/quality/helm_client_spec.rb index 795aa43b849c90ad79164b6e4b06e2f7c4c1163b..8d199fe3531ffafd21fcc2c309ce3340aa2d1c5d 100644 --- a/spec/lib/quality/helm_client_spec.rb +++ b/spec/lib/quality/helm_client_spec.rb @@ -110,7 +110,7 @@ RSpec.describe Quality::HelmClient do end context 'with multiple release names' do - let(:release_name) { ['my-release', 'my-release-2'] } + let(:release_name) { %w[my-release my-release-2] } it 'raises an error if the Helm command fails' do expect(Gitlab::Popen).to receive(:popen_with_detail) diff --git a/spec/lib/quality/kubernetes_client_spec.rb b/spec/lib/quality/kubernetes_client_spec.rb index 59d4a977d5e82fe0b23db1e34b159307efdab5d1..6a62ef456c18feb479f2927af32b18b44b3e424c 100644 --- a/spec/lib/quality/kubernetes_client_spec.rb +++ b/spec/lib/quality/kubernetes_client_spec.rb @@ -46,7 +46,7 @@ RSpec.describe Quality::KubernetesClient do end context 'with multiple releases' do - let(:release_name) { ['my-release', 'my-release-2'] } + let(:release_name) { %w[my-release my-release-2] } it 'raises an error if the Kubernetes command fails' do expect(Gitlab::Popen).to receive(:popen_with_detail) diff --git a/spec/lib/sentry/api_urls_spec.rb b/spec/lib/sentry/api_urls_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..78455f8d51f221b71ca57c68fd4eea82e2eeab82 --- /dev/null +++ b/spec/lib/sentry/api_urls_spec.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Sentry::ApiUrls do + let(:sentry_url) { 'https://sentrytest.gitlab.com/api/0/projects/sentry-org/sentry-project/' } + let(:token) { 'test-token' } + let(:issue_id) { '123456' } + let(:issue_id_with_reserved_chars) { '123$%' } + let(:escaped_issue_id) { '123%24%25' } + let(:api_urls) { Sentry::ApiUrls.new(sentry_url) } + + # Sentry API returns 404 if there are extra slashes in the URL! + shared_examples 'correct url with extra slashes' do + let(:sentry_url) { 'https://sentrytest.gitlab.com/api/0/projects//sentry-org/sentry-project/' } + + it_behaves_like 'correct url' + end + + shared_examples 'correctly escapes issue ID' do + context 'with param a string with reserved chars' do + let(:issue_id) { issue_id_with_reserved_chars } + + it { expect(subject.to_s).to include(escaped_issue_id) } + end + + context 'with param a symbol with reserved chars' do + let(:issue_id) { issue_id_with_reserved_chars.to_sym } + + it { expect(subject.to_s).to include(escaped_issue_id) } + end + + context 'with param an integer' do + let(:issue_id) { 12345678 } + + it { expect(subject.to_s).to include(issue_id.to_s) } + end + end + + describe '#issues_url' do + subject { api_urls.issues_url } + + shared_examples 'correct url' do + it { is_expected.to eq_uri('https://sentrytest.gitlab.com/api/0/projects/sentry-org/sentry-project/issues/') } + end + + it_behaves_like 'correct url' + it_behaves_like 'correct url with extra slashes' + end + + describe '#issue_url' do + subject { api_urls.issue_url(issue_id) } + + shared_examples 'correct url' do + it { is_expected.to eq_uri("https://sentrytest.gitlab.com/api/0/issues/#{issue_id}/") } + end + + it_behaves_like 'correct url' + it_behaves_like 'correct url with extra slashes' + it_behaves_like 'correctly escapes issue ID' + end + + describe '#projects_url' do + subject { api_urls.projects_url } + + shared_examples 'correct url' do + it { is_expected.to eq_uri('https://sentrytest.gitlab.com/api/0/projects/') } + end + + it_behaves_like 'correct url' + it_behaves_like 'correct url with extra slashes' + end + + describe '#issue_latest_event_url' do + subject { api_urls.issue_latest_event_url(issue_id) } + + shared_examples 'correct url' do + it { is_expected.to eq_uri("https://sentrytest.gitlab.com/api/0/issues/#{issue_id}/events/latest/") } + end + + it_behaves_like 'correct url' + it_behaves_like 'correct url with extra slashes' + it_behaves_like 'correctly escapes issue ID' + end +end diff --git a/spec/lib/sentry/client/event_spec.rb b/spec/lib/sentry/client/event_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..c8604d72adaf82e9f3ed28f02279a1b3cbcc8720 --- /dev/null +++ b/spec/lib/sentry/client/event_spec.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Sentry::Client do + include SentryClientHelpers + + let(:sentry_url) { 'https://sentrytest.gitlab.com/api/0/projects/sentry-org/sentry-project' } + let(:token) { 'test-token' } + let(:default_httparty_options) do + { + follow_redirects: false, + headers: { "Authorization" => "Bearer test-token" } + } + end + let(:client) { described_class.new(sentry_url, token) } + + describe '#issue_latest_event' do + let(:sample_response) do + Gitlab::Utils.deep_indifferent_access( + JSON.parse(fixture_file('sentry/issue_latest_event_sample_response.json')) + ) + end + let(:issue_id) { '1234' } + let(:sentry_api_response) { sample_response } + let(:sentry_url) { 'https://sentrytest.gitlab.com/api/0' } + let(:sentry_request_url) { "#{sentry_url}/issues/#{issue_id}/events/latest/" } + let!(:sentry_api_request) { stub_sentry_request(sentry_request_url, body: sentry_api_response) } + + subject { client.issue_latest_event(issue_id: issue_id) } + + it_behaves_like 'calls sentry api' + + it 'has correct return type' do + expect(subject).to be_a(Gitlab::ErrorTracking::ErrorEvent) + end + + shared_examples 'assigns error tracking event correctly' do + using RSpec::Parameterized::TableSyntax + + where(:event_object, :sentry_response) do + :issue_id | :groupID + :date_received | :dateReceived + end + + with_them do + it { expect(subject.public_send(event_object)).to eq(sentry_api_response.dig(*sentry_response)) } + end + end + + context 'error object created from sentry response' do + it_behaves_like 'assigns error tracking event correctly' + + it 'parses the stack trace' do + expect(subject.stack_trace_entries).to be_a Array + expect(subject.stack_trace_entries).not_to be_empty + end + + context 'error without stack trace' do + before do + sample_response['entries'] = [] + stub_sentry_request(sentry_request_url, body: sample_response) + end + + it_behaves_like 'assigns error tracking event correctly' + + it 'returns an empty array for stack_trace_entries' do + expect(subject.stack_trace_entries).to eq [] + end + end + end + end +end diff --git a/spec/lib/sentry/client/issue_link_spec.rb b/spec/lib/sentry/client/issue_link_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..35a69be6de5121458f3031254d9ca62a478087ff --- /dev/null +++ b/spec/lib/sentry/client/issue_link_spec.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Sentry::Client::IssueLink do + include SentryClientHelpers + + let(:error_tracking_setting) { create(:project_error_tracking_setting, api_url: sentry_url) } + let(:sentry_url) { 'https://sentrytest.gitlab.com/api/0/projects/sentry-org/sentry-project' } + let(:client) { error_tracking_setting.sentry_client } + + let(:issue_link_sample_response) { JSON.parse(fixture_file('sentry/issue_link_sample_response.json')) } + + describe '#create_issue_link' do + let(:integration_id) { 44444 } + let(:sentry_issue_id) { 11111111 } + let(:issue) { create(:issue, project: error_tracking_setting.project) } + + let(:sentry_issue_link_url) { "https://sentrytest.gitlab.com/api/0/groups/#{sentry_issue_id}/integrations/#{integration_id}/" } + let(:sentry_api_response) { issue_link_sample_response } + let!(:sentry_api_request) { stub_sentry_request(sentry_issue_link_url, :put, body: sentry_api_response, status: 201) } + + subject { client.create_issue_link(integration_id, sentry_issue_id, issue) } + + it_behaves_like 'calls sentry api' + + it { is_expected.to be_present } + + context 'redirects' do + let(:sentry_api_url) { sentry_issue_link_url } + + it_behaves_like 'no Sentry redirects', :put + end + + context 'when exception is raised' do + let(:sentry_request_url) { sentry_issue_link_url } + + it_behaves_like 'maps Sentry exceptions', :put + end + end +end diff --git a/spec/lib/sentry/client/issue_spec.rb b/spec/lib/sentry/client/issue_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..061ebcfdc0606aad272ae3554b926002f3888002 --- /dev/null +++ b/spec/lib/sentry/client/issue_spec.rb @@ -0,0 +1,299 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Sentry::Client::Issue do + include SentryClientHelpers + + let(:token) { 'test-token' } + let(:sentry_url) { 'https://sentrytest.gitlab.com/api/0' } + let(:client) { Sentry::Client.new(sentry_url, token) } + let(:issue_id) { 503504 } + + describe '#list_issues' do + shared_examples 'issues have correct return type' do |klass| + it "returns objects of type #{klass}" do + expect(subject[:issues]).to all( be_a(klass) ) + end + end + + shared_examples 'issues have correct length' do |length| + it { expect(subject[:issues].length).to eq(length) } + end + + let(:issues_sample_response) do + Gitlab::Utils.deep_indifferent_access( + JSON.parse(fixture_file('sentry/issues_sample_response.json')) + ) + end + + let(:default_httparty_options) do + { + follow_redirects: false, + headers: { 'Content-Type' => 'application/json', 'Authorization' => "Bearer test-token" } + } + end + + let(:sentry_url) { 'https://sentrytest.gitlab.com/api/0/projects/sentry-org/sentry-project' } + let(:issue_status) { 'unresolved' } + let(:limit) { 20 } + let(:search_term) { '' } + let(:cursor) { nil } + let(:sort) { 'last_seen' } + let(:sentry_api_response) { issues_sample_response } + let(:sentry_request_url) { sentry_url + '/issues/?limit=20&query=is:unresolved' } + let!(:sentry_api_request) { stub_sentry_request(sentry_request_url, body: sentry_api_response) } + + subject { client.list_issues(issue_status: issue_status, limit: limit, search_term: search_term, sort: sort, cursor: cursor) } + + it_behaves_like 'calls sentry api' + + it_behaves_like 'issues have correct return type', Gitlab::ErrorTracking::Error + it_behaves_like 'issues have correct length', 1 + + shared_examples 'has correct external_url' do + context 'external_url' do + it 'is constructed correctly' do + expect(subject[:issues][0].external_url).to eq('https://sentrytest.gitlab.com/sentry-org/sentry-project/issues/11') + end + end + end + + context 'when response has a pagination info' do + let(:headers) do + { + link: '<https://sentrytest.gitlab.com>; rel="previous"; results="true"; cursor="1573556671000:0:1", <https://sentrytest.gitlab.com>; rel="next"; results="true"; cursor="1572959139000:0:0"' + } + end + let!(:sentry_api_request) { stub_sentry_request(sentry_request_url, body: sentry_api_response, headers: headers) } + + it 'parses the pagination' do + expect(subject[:pagination]).to eq( + 'previous' => { 'cursor' => '1573556671000:0:1' }, + 'next' => { 'cursor' => '1572959139000:0:0' } + ) + end + end + + context 'error object created from sentry response' do + using RSpec::Parameterized::TableSyntax + + where(:error_object, :sentry_response) do + :id | :id + :first_seen | :firstSeen + :last_seen | :lastSeen + :title | :title + :type | :type + :user_count | :userCount + :count | :count + :message | [:metadata, :value] + :culprit | :culprit + :short_id | :shortId + :status | :status + :frequency | [:stats, '24h'] + :project_id | [:project, :id] + :project_name | [:project, :name] + :project_slug | [:project, :slug] + end + + with_them do + it { expect(subject[:issues][0].public_send(error_object)).to eq(sentry_api_response[0].dig(*sentry_response)) } + end + + it_behaves_like 'has correct external_url' + end + + context 'redirects' do + let(:sentry_api_url) { sentry_url + '/issues/?limit=20&query=is:unresolved' } + + it_behaves_like 'no Sentry redirects' + end + + context 'requests with sort parameter in sentry api' do + let(:sentry_request_url) do + 'https://sentrytest.gitlab.com/api/0/projects/sentry-org/sentry-project/' \ + 'issues/?limit=20&query=is:unresolved&sort=freq' + end + let!(:sentry_api_request) { stub_sentry_request(sentry_request_url, body: sentry_api_response) } + + subject { client.list_issues(issue_status: issue_status, limit: limit, sort: 'frequency') } + + it 'calls the sentry api with sort params' do + expect(Gitlab::HTTP).to receive(:get).with( + URI("#{sentry_url}/issues/"), + default_httparty_options.merge(query: { limit: 20, query: "is:unresolved", sort: "freq" }) + ).and_call_original + + subject + + expect(sentry_api_request).to have_been_requested + end + end + + context 'with invalid sort params' do + subject { client.list_issues(issue_status: issue_status, limit: limit, sort: 'fish') } + + it 'throws an error' do + expect { subject }.to raise_error(Sentry::Client::BadRequestError, 'Invalid value for sort param') + end + end + + context 'Older sentry versions where keys are not present' do + let(:sentry_api_response) do + issues_sample_response[0...1].map do |issue| + issue[:project].delete(:id) + issue + end + end + + it_behaves_like 'calls sentry api' + + it_behaves_like 'issues have correct return type', Gitlab::ErrorTracking::Error + it_behaves_like 'issues have correct length', 1 + + it_behaves_like 'has correct external_url' + end + + context 'essential keys missing in API response' do + let(:sentry_api_response) do + issues_sample_response[0...1].map do |issue| + issue.except(:id) + end + end + + it 'raises exception' do + expect { subject }.to raise_error(Sentry::Client::MissingKeysError, 'Sentry API response is missing keys. key not found: "id"') + end + end + + context 'sentry api response too large' do + it 'raises exception' do + deep_size = double('Gitlab::Utils::DeepSize', valid?: false) + allow(Gitlab::Utils::DeepSize).to receive(:new).with(sentry_api_response).and_return(deep_size) + + expect { subject }.to raise_error(Sentry::Client::ResponseInvalidSizeError, 'Sentry API response is too big. Limit is 1 MB.') + end + end + + it_behaves_like 'maps Sentry exceptions' + + context 'when search term is present' do + let(:search_term) { 'NoMethodError' } + let(:sentry_request_url) { "#{sentry_url}/issues/?limit=20&query=is:unresolved NoMethodError" } + + it_behaves_like 'calls sentry api' + + it_behaves_like 'issues have correct return type', Gitlab::ErrorTracking::Error + it_behaves_like 'issues have correct length', 1 + end + + context 'when cursor is present' do + let(:cursor) { '1572959139000:0:0' } + let(:sentry_request_url) { "#{sentry_url}/issues/?limit=20&cursor=#{cursor}&query=is:unresolved" } + + it_behaves_like 'calls sentry api' + + it_behaves_like 'issues have correct return type', Gitlab::ErrorTracking::Error + it_behaves_like 'issues have correct length', 1 + end + end + + describe '#issue_details' do + let(:issue_sample_response) do + Gitlab::Utils.deep_indifferent_access( + JSON.parse(fixture_file('sentry/issue_sample_response.json')) + ) + end + + let(:sentry_request_url) { "#{sentry_url}/issues/#{issue_id}/" } + let!(:sentry_api_request) { stub_sentry_request(sentry_request_url, body: issue_sample_response) } + + subject { client.issue_details(issue_id: issue_id) } + + context 'error object created from sentry response' do + using RSpec::Parameterized::TableSyntax + + where(:error_object, :sentry_response) do + :id | :id + :first_seen | :firstSeen + :last_seen | :lastSeen + :title | :title + :type | :type + :user_count | :userCount + :count | :count + :message | [:metadata, :value] + :culprit | :culprit + :short_id | :shortId + :status | :status + :frequency | [:stats, '24h'] + :project_id | [:project, :id] + :project_name | [:project, :name] + :project_slug | [:project, :slug] + :first_release_last_commit | [:firstRelease, :lastCommit] + :last_release_last_commit | [:lastRelease, :lastCommit] + :first_release_short_version | [:firstRelease, :shortVersion] + :last_release_short_version | [:lastRelease, :shortVersion] + :first_release_version | [:firstRelease, :version] + end + + with_them do + it do + expect(subject.public_send(error_object)).to eq(issue_sample_response.dig(*sentry_response)) + end + end + + it 'has a correct external URL' do + expect(subject.external_url).to eq('https://sentrytest.gitlab.com/api/0/issues/503504') + end + + it 'issue has a correct external base url' do + expect(subject.external_base_url).to eq('https://sentrytest.gitlab.com/api/0') + end + + it 'has a correct GitLab issue url' do + expect(subject.gitlab_issue).to eq('https://gitlab.com/gitlab-org/gitlab/issues/1') + end + + it 'has the correct tags' do + expect(subject.tags).to eq({ level: issue_sample_response['level'], logger: issue_sample_response['logger'] }) + end + end + end + + describe '#update_issue' do + let(:sentry_url) { 'https://sentrytest.gitlab.com/api/0' } + let(:sentry_request_url) { "#{sentry_url}/issues/#{issue_id}/" } + + before do + stub_sentry_request(sentry_request_url, :put) + end + + let(:params) do + { + status: 'resolved' + } + end + + subject { client.update_issue(issue_id: issue_id, params: params) } + + it_behaves_like 'calls sentry api' do + let(:sentry_api_request) { stub_sentry_request(sentry_request_url, :put) } + end + + it 'returns a truthy result' do + expect(subject).to be_truthy + end + + context 'error encountered' do + let(:error) { StandardError.new('error') } + + before do + allow(client).to receive(:update_issue).and_raise(error) + end + + it 'raises the error' do + expect { subject }.to raise_error(error) + end + end + end +end diff --git a/spec/lib/sentry/client/projects_spec.rb b/spec/lib/sentry/client/projects_spec.rb index 462f74eaac93ffd5e0d335185db52f1f8b729e6a..6183d4c5816d4c021309bf3aa9a8135221b3c8be 100644 --- a/spec/lib/sentry/client/projects_spec.rb +++ b/spec/lib/sentry/client/projects_spec.rb @@ -91,25 +91,6 @@ describe Sentry::Client::Projects do it_behaves_like 'no Sentry redirects' end - # Sentry API returns 404 if there are extra slashes in the URL! - context 'extra slashes in URL' do - let(:sentry_url) { 'https://sentrytest.gitlab.com/api//0/projects//' } - let!(:valid_req_stub) do - stub_sentry_request(sentry_list_projects_url) - end - - it 'removes extra slashes in api url' do - expect(Gitlab::HTTP).to receive(:get).with( - URI(sentry_list_projects_url), - anything - ).and_call_original - - subject - - expect(valid_req_stub).to have_been_requested - end - end - context 'when exception is raised' do let(:sentry_request_url) { sentry_list_projects_url } diff --git a/spec/lib/sentry/client/repo_spec.rb b/spec/lib/sentry/client/repo_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..7bc2811ef034007d8c416ffaeb0d68ce9a3cb300 --- /dev/null +++ b/spec/lib/sentry/client/repo_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Sentry::Client::Repo do + include SentryClientHelpers + + let(:sentry_url) { 'https://sentrytest.gitlab.com/api/0/projects/sentry-org/sentry-project' } + let(:token) { 'test-token' } + let(:client) { Sentry::Client.new(sentry_url, token) } + let(:repos_sample_response) { JSON.parse(fixture_file('sentry/repos_sample_response.json')) } + + describe '#repos' do + let(:organization_slug) { 'gitlab' } + let(:sentry_repos_url) { "https://sentrytest.gitlab.com/api/0/organizations/#{organization_slug}/repos/" } + let(:sentry_api_response) { repos_sample_response } + let!(:sentry_api_request) { stub_sentry_request(sentry_repos_url, body: sentry_api_response) } + + subject { client.repos(organization_slug) } + + it_behaves_like 'calls sentry api' + + it { is_expected.to all( be_a(Gitlab::ErrorTracking::Repo)) } + + it { expect(subject.length).to eq(1) } + + context 'redirects' do + let(:sentry_api_url) { sentry_repos_url } + + it_behaves_like 'no Sentry redirects' + end + + context 'when exception is raised' do + let(:sentry_request_url) { sentry_repos_url } + + it_behaves_like 'maps Sentry exceptions' + end + end +end diff --git a/spec/lib/sentry/client_spec.rb b/spec/lib/sentry/client_spec.rb index cff06bf4a5ffc78f14af84405fde8ada6da26fdb..e2da4564ca177d6df71db195a4fdb24869e743ad 100644 --- a/spec/lib/sentry/client_spec.rb +++ b/spec/lib/sentry/client_spec.rb @@ -3,219 +3,15 @@ require 'spec_helper' describe Sentry::Client do - include SentryClientHelpers - let(:sentry_url) { 'https://sentrytest.gitlab.com/api/0/projects/sentry-org/sentry-project' } let(:token) { 'test-token' } - let(:default_httparty_options) do - { - follow_redirects: false, - headers: { "Authorization" => "Bearer test-token" } - } - end - - let(:issues_sample_response) do - Gitlab::Utils.deep_indifferent_access( - JSON.parse(fixture_file('sentry/issues_sample_response.json')) - ) - end - - subject(:client) { described_class.new(sentry_url, token) } - - shared_examples 'issues has correct return type' do |klass| - it "returns objects of type #{klass}" do - expect(subject[:issues]).to all( be_a(klass) ) - end - end - - shared_examples 'issues has correct length' do |length| - it { expect(subject[:issues].length).to eq(length) } - end - - describe '#list_issues' do - let(:issue_status) { 'unresolved' } - let(:limit) { 20 } - let(:search_term) { '' } - let(:cursor) { nil } - let(:sort) { 'last_seen' } - let(:sentry_api_response) { issues_sample_response } - let(:sentry_request_url) { sentry_url + '/issues/?limit=20&query=is:unresolved' } - - let!(:sentry_api_request) { stub_sentry_request(sentry_request_url, body: sentry_api_response) } - - subject { client.list_issues(issue_status: issue_status, limit: limit, search_term: search_term, sort: sort, cursor: cursor) } - - it_behaves_like 'calls sentry api' - - it_behaves_like 'issues has correct return type', Gitlab::ErrorTracking::Error - it_behaves_like 'issues has correct length', 1 - - shared_examples 'has correct external_url' do - context 'external_url' do - it 'is constructed correctly' do - expect(subject[:issues][0].external_url).to eq('https://sentrytest.gitlab.com/sentry-org/sentry-project/issues/11') - end - end - end - - context 'when response has a pagination info' do - let(:headers) do - { - link: '<https://sentrytest.gitlab.com>; rel="previous"; results="true"; cursor="1573556671000:0:1", <https://sentrytest.gitlab.com>; rel="next"; results="true"; cursor="1572959139000:0:0"' - } - end - let!(:sentry_api_request) { stub_sentry_request(sentry_request_url, body: sentry_api_response, headers: headers) } - - it 'parses the pagination' do - expect(subject[:pagination]).to eq( - 'previous' => { 'cursor' => '1573556671000:0:1' }, - 'next' => { 'cursor' => '1572959139000:0:0' } - ) - end - end - - context 'error object created from sentry response' do - using RSpec::Parameterized::TableSyntax - - where(:error_object, :sentry_response) do - :id | :id - :first_seen | :firstSeen - :last_seen | :lastSeen - :title | :title - :type | :type - :user_count | :userCount - :count | :count - :message | [:metadata, :value] - :culprit | :culprit - :short_id | :shortId - :status | :status - :frequency | [:stats, '24h'] - :project_id | [:project, :id] - :project_name | [:project, :name] - :project_slug | [:project, :slug] - end - - with_them do - it { expect(subject[:issues][0].public_send(error_object)).to eq(sentry_api_response[0].dig(*sentry_response)) } - end - - it_behaves_like 'has correct external_url' - end - - context 'redirects' do - let(:sentry_api_url) { sentry_url + '/issues/?limit=20&query=is:unresolved' } - - it_behaves_like 'no Sentry redirects' - end - - # Sentry API returns 404 if there are extra slashes in the URL! - context 'extra slashes in URL' do - let(:sentry_url) { 'https://sentrytest.gitlab.com/api/0/projects//sentry-org/sentry-project/' } - - let(:sentry_request_url) do - 'https://sentrytest.gitlab.com/api/0/projects/sentry-org/sentry-project/' \ - 'issues/?limit=20&query=is:unresolved' - end - - it 'removes extra slashes in api url' do - expect(client.url).to eq(sentry_url) - expect(Gitlab::HTTP).to receive(:get).with( - URI('https://sentrytest.gitlab.com/api/0/projects/sentry-org/sentry-project/issues/'), - anything - ).and_call_original - - subject - - expect(sentry_api_request).to have_been_requested - end - end - - context 'requests with sort parameter in sentry api' do - let(:sentry_request_url) do - 'https://sentrytest.gitlab.com/api/0/projects/sentry-org/sentry-project/' \ - 'issues/?limit=20&query=is:unresolved&sort=freq' - end - let!(:sentry_api_request) { stub_sentry_request(sentry_request_url, body: sentry_api_response) } - - subject { client.list_issues(issue_status: issue_status, limit: limit, sort: 'frequency') } - - it 'calls the sentry api with sort params' do - expect(Gitlab::HTTP).to receive(:get).with( - URI("#{sentry_url}/issues/"), - default_httparty_options.merge(query: { limit: 20, query: "is:unresolved", sort: "freq" }) - ).and_call_original - - subject - - expect(sentry_api_request).to have_been_requested - end - end - - context 'with invalid sort params' do - subject { client.list_issues(issue_status: issue_status, limit: limit, sort: 'fish') } - - it 'throws an error' do - expect { subject }.to raise_error(Sentry::Client::BadRequestError, 'Invalid value for sort param') - end - end - - context 'Older sentry versions where keys are not present' do - let(:sentry_api_response) do - issues_sample_response[0...1].map do |issue| - issue[:project].delete(:id) - issue - end - end - - it_behaves_like 'calls sentry api' - - it_behaves_like 'issues has correct return type', Gitlab::ErrorTracking::Error - it_behaves_like 'issues has correct length', 1 - - it_behaves_like 'has correct external_url' - end - - context 'essential keys missing in API response' do - let(:sentry_api_response) do - issues_sample_response[0...1].map do |issue| - issue.except(:id) - end - end - - it 'raises exception' do - expect { subject }.to raise_error(Sentry::Client::MissingKeysError, 'Sentry API response is missing keys. key not found: "id"') - end - end - - context 'sentry api response too large' do - it 'raises exception' do - deep_size = double('Gitlab::Utils::DeepSize', valid?: false) - allow(Gitlab::Utils::DeepSize).to receive(:new).with(sentry_api_response).and_return(deep_size) - - expect { subject }.to raise_error(Sentry::Client::ResponseInvalidSizeError, 'Sentry API response is too big. Limit is 1 MB.') - end - end - - it_behaves_like 'maps Sentry exceptions' - - context 'when search term is present' do - let(:search_term) { 'NoMethodError' } - let(:sentry_request_url) { "#{sentry_url}/issues/?limit=20&query=is:unresolved NoMethodError" } - - it_behaves_like 'calls sentry api' - - it_behaves_like 'issues has correct return type', Gitlab::ErrorTracking::Error - it_behaves_like 'issues has correct length', 1 - end - - context 'when cursor is present' do - let(:cursor) { '1572959139000:0:0' } - let(:sentry_request_url) { "#{sentry_url}/issues/?limit=20&cursor=#{cursor}&query=is:unresolved" } - it_behaves_like 'calls sentry api' + subject { Sentry::Client.new(sentry_url, token) } - it_behaves_like 'issues has correct return type', Gitlab::ErrorTracking::Error - it_behaves_like 'issues has correct length', 1 - end - end + it { is_expected.to respond_to :projects } + it { is_expected.to respond_to :list_issues } + it { is_expected.to respond_to :issue_details } + it { is_expected.to respond_to :issue_latest_event } + it { is_expected.to respond_to :repos } + it { is_expected.to respond_to :create_issue_link } end diff --git a/spec/migrations/20190924152703_migrate_issue_trackers_data_spec.rb b/spec/migrations/20190924152703_migrate_issue_trackers_data_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..53c176fc46f897e8091959655ffc2cc3f776bd66 --- /dev/null +++ b/spec/migrations/20190924152703_migrate_issue_trackers_data_spec.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require 'spec_helper' +require Rails.root.join('db', 'post_migrate', '20190924152703_migrate_issue_trackers_data.rb') + +describe MigrateIssueTrackersData, :migration, :sidekiq do + let(:services) { table(:services) } + let(:migration_class) { Gitlab::BackgroundMigration::MigrateIssueTrackersSensitiveData } + let(:migration_name) { migration_class.to_s.demodulize } + + let(:properties) do + { + 'url' => 'http://example.com' + } + end + let!(:jira_service) do + services.create(id: 10, type: 'JiraService', properties: properties, category: 'issue_tracker') + end + let!(:jira_service_nil) do + services.create(id: 11, type: 'JiraService', properties: nil, category: 'issue_tracker') + end + let!(:bugzilla_service) do + services.create(id: 12, type: 'BugzillaService', properties: properties, category: 'issue_tracker') + end + let!(:youtrack_service) do + services.create(id: 13, type: 'YoutrackService', properties: properties, category: 'issue_tracker') + end + let!(:youtrack_service_empty) do + services.create(id: 14, type: 'YoutrackService', properties: '', category: 'issue_tracker') + end + let!(:gitlab_service) do + services.create(id: 15, type: 'GitlabIssueTrackerService', properties: properties, category: 'issue_tracker') + end + let!(:gitlab_service_empty) do + services.create(id: 16, type: 'GitlabIssueTrackerService', properties: {}, category: 'issue_tracker') + end + let!(:other_service) do + services.create(id: 17, type: 'OtherService', properties: properties, category: 'other_category') + end + + before do + stub_const("#{described_class}::BATCH_SIZE", 2) + end + + it 'schedules background migrations at correct time' do + Sidekiq::Testing.fake! do + Timecop.freeze do + migrate! + + expect(migration_name).to be_scheduled_delayed_migration(3.minutes, jira_service.id, bugzilla_service.id) + expect(migration_name).to be_scheduled_delayed_migration(6.minutes, youtrack_service.id, gitlab_service.id) + expect(BackgroundMigrationWorker.jobs.size).to eq(2) + end + end + end +end diff --git a/spec/migrations/20191204114127_delete_legacy_triggers_spec.rb b/spec/migrations/20191204114127_delete_legacy_triggers_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..c2660d699ca4f0913d62d6fb12e5be1e46672eeb --- /dev/null +++ b/spec/migrations/20191204114127_delete_legacy_triggers_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require 'spec_helper' +require Rails.root.join('db', 'post_migrate', '20191204114127_delete_legacy_triggers.rb') + +describe DeleteLegacyTriggers, :migration, schema: 2019_11_25_140458 do + let(:ci_trigger_table) { table(:ci_triggers) } + let(:user) { table(:users).create!(name: 'test', email: 'test@example.com', projects_limit: 1) } + + before do + @trigger_with_user = ci_trigger_table.create!(owner_id: user.id) + ci_trigger_table.create!(owner_id: nil) + ci_trigger_table.create!(owner_id: nil) + end + + it 'removes legacy triggers which has null owner_id' do + expect do + migrate! + end.to change(ci_trigger_table, :count).by(-2) + + expect(ci_trigger_table.all).to eq([@trigger_with_user]) + end +end diff --git a/spec/migrations/20200107172020_add_timestamp_softwarelicensespolicy_spec.rb b/spec/migrations/20200107172020_add_timestamp_softwarelicensespolicy_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..b0d2aea7015ea504c34cd23f10a9aec4d9f06947 --- /dev/null +++ b/spec/migrations/20200107172020_add_timestamp_softwarelicensespolicy_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require 'spec_helper' +require Rails.root.join('db', 'migrate', '20200107172020_add_timestamp_softwarelicensespolicy.rb') + +describe AddTimestampSoftwarelicensespolicy, :migration do + let(:software_licenses_policy) { table(:software_license_policies) } + let(:projects) { table(:projects) } + let(:licenses) { table(:software_licenses) } + + before do + projects.create!(name: 'gitlab', path: 'gitlab-org/gitlab-ce', namespace_id: 1) + licenses.create!(name: 'MIT') + software_licenses_policy.create!(project_id: projects.first.id, software_license_id: licenses.first.id) + end + + it 'creates timestamps' do + migrate! + + expect(software_licenses_policy.first.created_at).to be_nil + expect(software_licenses_policy.first.updated_at).to be_nil + end +end diff --git a/spec/migrations/add_temporary_partial_index_on_project_id_to_services_spec.rb b/spec/migrations/add_temporary_partial_index_on_project_id_to_services_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..2d12fec5cb3232890290a1e0dca80c5221a4088d --- /dev/null +++ b/spec/migrations/add_temporary_partial_index_on_project_id_to_services_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require 'spec_helper' +require Rails.root.join('db', 'post_migrate', '20200114112932_add_temporary_partial_index_on_project_id_to_services.rb') + +describe AddTemporaryPartialIndexOnProjectIdToServices, :migration do + let(:migration) { described_class.new } + + describe '#up' do + it 'creates temporary partial index on type' do + expect { migration.up }.to change { migration.index_exists?(:services, :project_id, name: described_class::INDEX_NAME) }.from(false).to(true) + end + end + + describe '#down' do + it 'removes temporary partial index on type' do + migration.up + + expect { migration.down }.to change { migration.index_exists?(:services, :project_id, name: described_class::INDEX_NAME) }.from(true).to(false) + end + end +end diff --git a/spec/migrations/backfill_operations_feature_flags_active_spec.rb b/spec/migrations/backfill_operations_feature_flags_active_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..ad69b776052cdf2e4c423e5c2c1eb0350fd8a496 --- /dev/null +++ b/spec/migrations/backfill_operations_feature_flags_active_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require 'spec_helper' +require Rails.root.join('db', 'migrate', '20191213184609_backfill_operations_feature_flags_active.rb') + +describe BackfillOperationsFeatureFlagsActive, :migration do + let(:namespaces) { table(:namespaces) } + let(:projects) { table(:projects) } + let(:flags) { table(:operations_feature_flags) } + + def setup + namespace = namespaces.create!(name: 'foo', path: 'foo') + project = projects.create!(namespace_id: namespace.id) + + project + end + + it 'executes successfully when there are no flags in the table' do + setup + + disable_migrations_output { migrate! } + + expect(flags.count).to eq(0) + end + + it 'updates active to true' do + project = setup + flag = flags.create!(project_id: project.id, name: 'test_flag', active: false) + + disable_migrations_output { migrate! } + + expect(flag.reload.active).to eq(true) + end + + it 'updates active to true for multiple flags' do + project = setup + flag_a = flags.create!(project_id: project.id, name: 'test_flag', active: false) + flag_b = flags.create!(project_id: project.id, name: 'other_flag', active: false) + + disable_migrations_output { migrate! } + + expect(flag_a.reload.active).to eq(true) + expect(flag_b.reload.active).to eq(true) + end + + it 'leaves active true if it is already true' do + project = setup + flag = flags.create!(project_id: project.id, name: 'test_flag', active: true) + + disable_migrations_output { migrate! } + + expect(flag.reload.active).to eq(true) + end +end diff --git a/spec/migrations/drop_project_ci_cd_settings_merge_trains_enabled_spec.rb b/spec/migrations/drop_project_ci_cd_settings_merge_trains_enabled_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..1b0e6e140ca3d8b901c859c596c5b279bd83df96 --- /dev/null +++ b/spec/migrations/drop_project_ci_cd_settings_merge_trains_enabled_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require 'spec_helper' +require Rails.root.join('db', 'post_migrate', '20191128162854_drop_project_ci_cd_settings_merge_trains_enabled.rb') + +describe DropProjectCiCdSettingsMergeTrainsEnabled, :migration do + let!(:project_ci_cd_setting) { table(:project_ci_cd_settings) } + + it 'correctly migrates up and down' do + reversible_migration do |migration| + migration.before -> { + expect(project_ci_cd_setting.column_names).to include("merge_trains_enabled") + } + + migration.after -> { + project_ci_cd_setting.reset_column_information + expect(project_ci_cd_setting.column_names).not_to include("merge_trains_enabled") + } + end + end +end diff --git a/spec/migrations/fix_max_pages_size_spec.rb b/spec/migrations/fix_max_pages_size_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..36b5445603edfe11a4bb370c4972cdcccd7e9fc6 --- /dev/null +++ b/spec/migrations/fix_max_pages_size_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require 'spec_helper' +require Rails.root.join('db', 'migrate', '20191213120427_fix_max_pages_size.rb') + +describe FixMaxPagesSize, :migration do + let(:application_settings) { table(:application_settings) } + let!(:default_setting) { application_settings.create! } + let!(:max_possible_setting) { application_settings.create!(max_pages_size: described_class::MAX_SIZE) } + let!(:higher_than_maximum_setting) { application_settings.create!(max_pages_size: described_class::MAX_SIZE + 1) } + + it 'correctly updates settings only if needed' do + migrate! + + expect(default_setting.reload.max_pages_size).to eq(100) + expect(max_possible_setting.reload.max_pages_size).to eq(described_class::MAX_SIZE) + expect(higher_than_maximum_setting.reload.max_pages_size).to eq(described_class::MAX_SIZE) + end +end diff --git a/spec/migrations/patch_prometheus_services_for_shared_cluster_applications_spec.rb b/spec/migrations/patch_prometheus_services_for_shared_cluster_applications_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..83f994c2a94a9af25d51570c8158dda7ae7c03a5 --- /dev/null +++ b/spec/migrations/patch_prometheus_services_for_shared_cluster_applications_spec.rb @@ -0,0 +1,134 @@ +# frozen_string_literal: true + +require 'spec_helper' +require Rails.root.join('db', 'post_migrate', '20200114113341_patch_prometheus_services_for_shared_cluster_applications.rb') + +describe PatchPrometheusServicesForSharedClusterApplications, :migration, :sidekiq do + include MigrationHelpers::PrometheusServiceHelpers + + let(:namespaces) { table(:namespaces) } + let(:projects) { table(:projects) } + let(:services) { table(:services) } + let(:clusters) { table(:clusters) } + let(:cluster_groups) { table(:cluster_groups) } + let(:clusters_applications_prometheus) { table(:clusters_applications_prometheus) } + let(:namespace) { namespaces.create!(name: 'gitlab', path: 'gitlab-org') } + + let(:application_statuses) do + { + errored: -1, + installed: 3, + updated: 5 + } + end + + let(:cluster_types) do + { + instance_type: 1, + group_type: 2 + } + end + + describe '#up' do + let!(:project_with_missing_service) { projects.create!(name: 'gitlab', path: 'gitlab-ce', namespace_id: namespace.id) } + let(:project_with_inactive_service) { projects.create!(name: 'gitlab', path: 'gitlab-ee', namespace_id: namespace.id) } + let(:project_with_active_service) { projects.create!(name: 'gitlab', path: 'gitlab-ee', namespace_id: namespace.id) } + let(:project_with_manual_active_service) { projects.create!(name: 'gitlab', path: 'gitlab-ee', namespace_id: namespace.id) } + let(:project_with_manual_inactive_service) { projects.create!(name: 'gitlab', path: 'gitlab-ee', namespace_id: namespace.id) } + let(:project_with_active_not_prometheus_service) { projects.create!(name: 'gitlab', path: 'gitlab-ee', namespace_id: namespace.id) } + let(:project_with_inactive_not_prometheus_service) { projects.create!(name: 'gitlab', path: 'gitlab-ee', namespace_id: namespace.id) } + + before do + services.create(service_params_for(project_with_inactive_service.id, active: false)) + services.create(service_params_for(project_with_active_service.id, active: true)) + services.create(service_params_for(project_with_active_not_prometheus_service.id, active: true, type: 'other')) + services.create(service_params_for(project_with_inactive_not_prometheus_service.id, active: false, type: 'other')) + services.create(service_params_for(project_with_manual_inactive_service.id, active: false, properties: { some: 'data' }.to_json)) + services.create(service_params_for(project_with_manual_active_service.id, active: true, properties: { some: 'data' }.to_json)) + end + + shared_examples 'patch prometheus services post migration' do + context 'prometheus application is installed on the cluster' do + it 'schedules a background migration' do + clusters_applications_prometheus.create(cluster_id: cluster.id, status: application_statuses[:installed], version: '123') + + Sidekiq::Testing.fake! do + Timecop.freeze do + background_migrations = [["ActivatePrometheusServicesForSharedClusterApplications", project_with_missing_service.id], + ["ActivatePrometheusServicesForSharedClusterApplications", project_with_inactive_service.id], + ["ActivatePrometheusServicesForSharedClusterApplications", project_with_active_not_prometheus_service.id], + ["ActivatePrometheusServicesForSharedClusterApplications", project_with_inactive_not_prometheus_service.id]] + + migrate! + + enqueued_migrations = BackgroundMigrationWorker.jobs.map { |job| job['args'] } + expect(enqueued_migrations).to match_array(background_migrations) + end + end + end + end + + context 'prometheus application was recently updated on the cluster' do + it 'schedules a background migration' do + clusters_applications_prometheus.create(cluster_id: cluster.id, status: application_statuses[:updated], version: '123') + + Sidekiq::Testing.fake! do + Timecop.freeze do + background_migrations = [["ActivatePrometheusServicesForSharedClusterApplications", project_with_missing_service.id], + ["ActivatePrometheusServicesForSharedClusterApplications", project_with_inactive_service.id], + ["ActivatePrometheusServicesForSharedClusterApplications", project_with_active_not_prometheus_service.id], + ["ActivatePrometheusServicesForSharedClusterApplications", project_with_inactive_not_prometheus_service.id]] + + migrate! + + enqueued_migrations = BackgroundMigrationWorker.jobs.map { |job| job['args'] } + expect(enqueued_migrations).to match_array(background_migrations) + end + end + end + end + + context 'prometheus application failed to install on the cluster' do + it 'does not schedule a background migration' do + clusters_applications_prometheus.create(cluster_id: cluster.id, status: application_statuses[:errored], version: '123') + + Sidekiq::Testing.fake! do + Timecop.freeze do + migrate! + + expect(BackgroundMigrationWorker.jobs.size).to eq 0 + end + end + end + end + + context 'prometheus application is NOT installed on the cluster' do + it 'does not schedule a background migration' do + Sidekiq::Testing.fake! do + Timecop.freeze do + migrate! + + expect(BackgroundMigrationWorker.jobs.size).to eq 0 + end + end + end + end + end + + context 'Cluster is group_type' do + let(:cluster) { clusters.create(name: 'cluster', cluster_type: cluster_types[:group_type]) } + + before do + cluster_groups.create(group_id: namespace.id, cluster_id: cluster.id) + end + + it_behaves_like 'patch prometheus services post migration' + end + + context 'Cluster is instance_type' do + let(:cluster) { clusters.create(name: 'cluster', cluster_type: cluster_types[:instance_type]) } + + it_behaves_like 'patch prometheus services post migration' + end + end +end diff --git a/spec/migrations/update_fingerprint_sha256_within_keys_spec.rb b/spec/migrations/update_fingerprint_sha256_within_keys_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..bdb661af9045f4906853379589682dc9172b67cd --- /dev/null +++ b/spec/migrations/update_fingerprint_sha256_within_keys_spec.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require 'spec_helper' + +require Rails.root.join('db', 'post_migrate', '20200106071113_update_fingerprint_sha256_within_keys.rb') + +describe UpdateFingerprintSha256WithinKeys, :sidekiq, :migration do + let(:key_table) { table(:keys) } + + describe '#up' do + it 'the BackgroundMigrationWorker will be triggered and fingerprint_sha256 populated' do + key_table.create!( + id: 1, + user_id: 1, + title: 'test', + key: 'ssh-rsa AAAAB3NzaC1yc2EAAAABJQAAAIEAiPWx6WM4lhHNedGfBpPJNPpZ7yKu+dnn1SJejgt1016k6YjzGGphH2TUxwKzxcKDKKezwkpfnxPkSMkuEspGRt/aZZ9wa++Oi7Qkr8prgHc4soW6NUlfDzpvZK2H5E7eQaSeP3SAwGmQKUFHCddNaP0L+hM7zhFNzjFvpaMgJw0=', + fingerprint: 'ba:81:59:68:d7:6c:cd:02:02:bf:6a:9b:55:4e:af:d1', + fingerprint_sha256: nil + ) + + expect(Key.first.fingerprint_sha256).to eq(nil) + + described_class.new.up + + expect(BackgroundMigrationWorker.jobs.size).to eq(1) + expect(BackgroundMigrationWorker.jobs.first["args"][0]).to eq("MigrateFingerprintSha256WithinKeys") + expect(BackgroundMigrationWorker.jobs.first["args"][1]).to eq([1, 1]) + end + end +end diff --git a/spec/models/active_session_spec.rb b/spec/models/active_session_spec.rb index 072d0fa86e55cdbf7e7a1cd77f0bb74dc89f78ac..bff3ac313c4c5744d85f6d40d91dd11ed25d1591 100644 --- a/spec/models/active_session_spec.rb +++ b/spec/models/active_session_spec.rb @@ -44,6 +44,19 @@ RSpec.describe ActiveSession, :clean_gitlab_redis_shared_state do end end + describe '#public_id' do + it 'returns an encrypted, url-encoded session id' do + original_session_id = "!*'();:@&\n=+$,/?%abcd#123[4567]8" + active_session = ActiveSession.new(session_id: original_session_id) + encrypted_encoded_id = active_session.public_id + + encrypted_id = CGI.unescape(encrypted_encoded_id) + derived_session_id = Gitlab::CryptoHelper.aes256_gcm_decrypt(encrypted_id) + + expect(original_session_id).to eq derived_session_id + end + end + describe '.list' do it 'returns all sessions by user' do Gitlab::Redis::SharedState.with do |redis| @@ -139,7 +152,7 @@ RSpec.describe ActiveSession, :clean_gitlab_redis_shared_state do redis = double(:redis) expect(Gitlab::Redis::SharedState).to receive(:with).and_yield(redis) - sessions = ['session-a', 'session-b'] + sessions = %w[session-a session-b] mget_responses = sessions.map { |session| [Marshal.dump(session)]} expect(redis).to receive(:mget).twice.and_return(*mget_responses) @@ -173,8 +186,7 @@ RSpec.describe ActiveSession, :clean_gitlab_redis_shared_state do device_name: 'iPhone 6', device_type: 'smartphone', created_at: Time.zone.parse('2018-03-12 09:06'), - updated_at: Time.zone.parse('2018-03-12 09:06'), - session_id: '6919a6f1bb119dd7396fadc38fd18d0d' + updated_at: Time.zone.parse('2018-03-12 09:06') ) end end @@ -244,6 +256,40 @@ RSpec.describe ActiveSession, :clean_gitlab_redis_shared_state do end end + describe '.destroy_with_public_id' do + it 'receives a user and public id and destroys the associated session' do + ActiveSession.set(user, request) + session = ActiveSession.list(user).first + + ActiveSession.destroy_with_public_id(user, session.public_id) + + total_sessions = ActiveSession.list(user).count + expect(total_sessions).to eq 0 + end + + it 'handles invalid input for public id' do + expect do + ActiveSession.destroy_with_public_id(user, nil) + end.not_to raise_error + + expect do + ActiveSession.destroy_with_public_id(user, "") + end.not_to raise_error + + expect do + ActiveSession.destroy_with_public_id(user, "aaaaaaaa") + end.not_to raise_error + end + + it 'does not attempt to destroy session when given invalid input for public id' do + expect(ActiveSession).not_to receive(:destroy) + + ActiveSession.destroy_with_public_id(user, nil) + ActiveSession.destroy_with_public_id(user, "") + ActiveSession.destroy_with_public_id(user, "aaaaaaaa") + end + end + describe '.cleanup' do before do stub_const("ActiveSession::ALLOWED_NUMBER_OF_ACTIVE_SESSIONS", 5) diff --git a/spec/models/application_setting_spec.rb b/spec/models/application_setting_spec.rb index a403aa296d4540af1d215037b49e581e19ae43e9..bbd50f1c0ef2734585ed694c5b4fa07e7e3f79a7 100644 --- a/spec/models/application_setting_spec.rb +++ b/spec/models/application_setting_spec.rb @@ -67,6 +67,13 @@ describe ApplicationSetting do it { is_expected.not_to allow_value(nil).for(:push_event_activities_limit) } it { is_expected.to validate_numericality_of(:snippet_size_limit).only_integer.is_greater_than(0) } + it { is_expected.to validate_presence_of(:max_artifacts_size) } + it do + is_expected.to validate_numericality_of(:max_pages_size).only_integer.is_greater_than(0) + .is_less_than(::Gitlab::Pages::MAX_SIZE / 1.megabyte) + end + it { is_expected.to validate_numericality_of(:max_artifacts_size).only_integer.is_greater_than(0) } + it { is_expected.to validate_numericality_of(:max_pages_size).only_integer.is_greater_than(0) } it { is_expected.not_to allow_value(7).for(:minimum_password_length) } it { is_expected.not_to allow_value(129).for(:minimum_password_length) } @@ -312,6 +319,11 @@ describe ApplicationSetting do end context 'gitaly timeouts' do + it "validates that the default_timeout is lower than the max_request_duration" do + is_expected.to validate_numericality_of(:gitaly_timeout_default) + .is_less_than_or_equal_to(Settings.gitlab.max_request_duration_seconds) + end + [:gitaly_timeout_default, :gitaly_timeout_medium, :gitaly_timeout_fast].each do |timeout_name| it do is_expected.to validate_presence_of(timeout_name) diff --git a/spec/models/blob_spec.rb b/spec/models/blob_spec.rb index 2c141cae98dc1480caa6e2492fb6767c184e53ab..c7ca0625b77bfaa47a4872260fc00c4cb2b112da 100644 --- a/spec/models/blob_spec.rb +++ b/spec/models/blob_spec.rb @@ -424,6 +424,7 @@ describe Blob do describe 'policy' do let(:project) { build(:project) } + subject { described_class.new(fake_blob(path: 'foo'), project) } it 'works with policy' do diff --git a/spec/models/blob_viewer/changelog_spec.rb b/spec/models/blob_viewer/changelog_spec.rb index 0fcc94182af169fee1290daadba12e861250172c..b71531ff3c24340ea016cc75914978020684e31c 100644 --- a/spec/models/blob_viewer/changelog_spec.rb +++ b/spec/models/blob_viewer/changelog_spec.rb @@ -7,6 +7,7 @@ describe BlobViewer::Changelog do let(:project) { create(:project, :repository) } let(:blob) { fake_blob(path: 'CHANGELOG') } + subject { described_class.new(blob) } describe '#render_error' do diff --git a/spec/models/blob_viewer/composer_json_spec.rb b/spec/models/blob_viewer/composer_json_spec.rb index eda34779679aa39e109dc67578b02747e63df269..a6bb64ba1213ead818506214f31a5f564e8127e1 100644 --- a/spec/models/blob_viewer/composer_json_spec.rb +++ b/spec/models/blob_viewer/composer_json_spec.rb @@ -15,6 +15,7 @@ describe BlobViewer::ComposerJson do SPEC end let(:blob) { fake_blob(path: 'composer.json', data: data) } + subject { described_class.new(blob) } describe '#package_name' do diff --git a/spec/models/blob_viewer/gemspec_spec.rb b/spec/models/blob_viewer/gemspec_spec.rb index b6cc82c03baed016787be0a6cce2f0d95a26e546..291d14e2d7270a4db47b6c24353fbae130bca26e 100644 --- a/spec/models/blob_viewer/gemspec_spec.rb +++ b/spec/models/blob_viewer/gemspec_spec.rb @@ -15,6 +15,7 @@ describe BlobViewer::Gemspec do SPEC end let(:blob) { fake_blob(path: 'activerecord.gemspec', data: data) } + subject { described_class.new(blob) } describe '#package_name' do diff --git a/spec/models/blob_viewer/gitlab_ci_yml_spec.rb b/spec/models/blob_viewer/gitlab_ci_yml_spec.rb index db405ceb4f1c533e99458196b8db86e5f11bb70f..02993052124d8cd2123a1e86c369c4d184cdf372 100644 --- a/spec/models/blob_viewer/gitlab_ci_yml_spec.rb +++ b/spec/models/blob_viewer/gitlab_ci_yml_spec.rb @@ -12,6 +12,7 @@ describe BlobViewer::GitlabCiYml do let(:data) { File.read(Rails.root.join('spec/support/gitlab_stubs/gitlab_ci.yml')) } let(:blob) { fake_blob(path: '.gitlab-ci.yml', data: data) } let(:sha) { sample_commit.id } + subject { described_class.new(blob) } describe '#validation_message' do diff --git a/spec/models/blob_viewer/license_spec.rb b/spec/models/blob_viewer/license_spec.rb index e02bfae3829a1c8cc533a2485a2b8990286f7a1e..b0426401932ccaba79d36501ae08cc844d1fd10d 100644 --- a/spec/models/blob_viewer/license_spec.rb +++ b/spec/models/blob_viewer/license_spec.rb @@ -7,6 +7,7 @@ describe BlobViewer::License do let(:project) { create(:project, :repository) } let(:blob) { fake_blob(path: 'LICENSE') } + subject { described_class.new(blob) } describe '#license' do diff --git a/spec/models/blob_viewer/package_json_spec.rb b/spec/models/blob_viewer/package_json_spec.rb index b317278f3c853d86a9ad85c9b89a1df28356ebe7..7f7b1dcfcb3f224e929a4033444c9b62f861dd83 100644 --- a/spec/models/blob_viewer/package_json_spec.rb +++ b/spec/models/blob_viewer/package_json_spec.rb @@ -15,6 +15,7 @@ describe BlobViewer::PackageJson do SPEC end let(:blob) { fake_blob(path: 'package.json', data: data) } + subject { described_class.new(blob) } describe '#package_name' do @@ -54,6 +55,7 @@ describe BlobViewer::PackageJson do SPEC end let(:blob) { fake_blob(path: 'package.json', data: data) } + subject { described_class.new(blob) } describe '#package_url' do diff --git a/spec/models/blob_viewer/podspec_json_spec.rb b/spec/models/blob_viewer/podspec_json_spec.rb index 7f1fb8666fdeb2b826eba610fc909784808bd169..dd5ed03b77d997cda944f086e48f7a521bfab321 100644 --- a/spec/models/blob_viewer/podspec_json_spec.rb +++ b/spec/models/blob_viewer/podspec_json_spec.rb @@ -15,6 +15,7 @@ describe BlobViewer::PodspecJson do SPEC end let(:blob) { fake_blob(path: 'AFNetworking.podspec.json', data: data) } + subject { described_class.new(blob) } describe '#package_name' do diff --git a/spec/models/blob_viewer/podspec_spec.rb b/spec/models/blob_viewer/podspec_spec.rb index 527ae79d766d19de0344dd9f408181c3cd96ba1b..2d9b184c5cb9b6d1b9835ce2962039875fd3191f 100644 --- a/spec/models/blob_viewer/podspec_spec.rb +++ b/spec/models/blob_viewer/podspec_spec.rb @@ -15,6 +15,7 @@ describe BlobViewer::Podspec do SPEC end let(:blob) { fake_blob(path: 'Reachability.podspec', data: data) } + subject { described_class.new(blob) } describe '#package_name' do diff --git a/spec/models/blob_viewer/readme_spec.rb b/spec/models/blob_viewer/readme_spec.rb index 958927bddb4cc801b5d4d07ee259662e0c3a8260..6586adbc373052f8c112d037fbdf7678e54b68b2 100644 --- a/spec/models/blob_viewer/readme_spec.rb +++ b/spec/models/blob_viewer/readme_spec.rb @@ -7,6 +7,7 @@ describe BlobViewer::Readme do let(:project) { create(:project, :repository, :wiki_repo) } let(:blob) { fake_blob(path: 'README.md') } + subject { described_class.new(blob) } describe '#render_error' do diff --git a/spec/models/blob_viewer/route_map_spec.rb b/spec/models/blob_viewer/route_map_spec.rb index f7ce873c9d1faa316880fdccdd398df62b4d9990..6c703df5c4cfdd93ea17cc6dd5aab73b66185d1b 100644 --- a/spec/models/blob_viewer/route_map_spec.rb +++ b/spec/models/blob_viewer/route_map_spec.rb @@ -14,6 +14,7 @@ describe BlobViewer::RouteMap do MAP end let(:blob) { fake_blob(path: '.gitlab/route-map.yml', data: data) } + subject { described_class.new(blob) } describe '#validation_message' do diff --git a/spec/models/board_spec.rb b/spec/models/board_spec.rb index f6eee67e5393bd0b5950800dfe6841e176ebdff8..0987c8e2b65da682776ff486e35013c620e2120f 100644 --- a/spec/models/board_spec.rb +++ b/spec/models/board_spec.rb @@ -3,6 +3,9 @@ require 'spec_helper' describe Board do + let(:project) { create(:project) } + let(:other_project) { create(:project) } + describe 'relationships' do it { is_expected.to belong_to(:project) } it { is_expected.to have_many(:lists).order(list_type: :asc, position: :asc).dependent(:delete_all) } @@ -11,4 +14,28 @@ describe Board do describe 'validations' do it { is_expected.to validate_presence_of(:project) } end + + describe '#order_by_name_asc' do + let!(:second_board) { create(:board, name: 'Secondary board', project: project) } + let!(:first_board) { create(:board, name: 'First board', project: project) } + + it 'returns in alphabetical order' do + expect(project.boards.order_by_name_asc).to eq [first_board, second_board] + end + end + + describe '#first_board' do + let!(:other_board) { create(:board, name: 'Other board', project: other_project) } + let!(:second_board) { create(:board, name: 'Secondary board', project: project) } + let!(:first_board) { create(:board, name: 'First board', project: project) } + + it 'return the first alphabetical board as a relation' do + expect(project.boards.first_board).to eq [first_board] + end + + # BoardsActions#board expects this behavior + it 'raises an error when find is done on a non-existent record' do + expect { project.boards.first_board.find(second_board.id) }.to raise_error(ActiveRecord::RecordNotFound) + end + end end diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb index 900e0feacccf4d9f5539bddcd10420462008685a..38e15fc4582886b3fcdc1fbb7a0baede37a90064 100644 --- a/spec/models/ci/build_spec.rb +++ b/spec/models/ci/build_spec.rb @@ -341,6 +341,36 @@ describe Ci::Build do end end + describe '#enqueue_preparing' do + let(:build) { create(:ci_build, :preparing) } + + subject { build.enqueue_preparing } + + before do + allow(build).to receive(:any_unmet_prerequisites?).and_return(has_unmet_prerequisites) + end + + context 'build completed prerequisites' do + let(:has_unmet_prerequisites) { false } + + it 'transitions to pending' do + subject + + expect(build).to be_pending + end + end + + context 'build did not complete prerequisites' do + let(:has_unmet_prerequisites) { true } + + it 'remains in preparing' do + subject + + expect(build).to be_preparing + end + end + end + describe '#actionize' do context 'when build is a created' do before do @@ -610,6 +640,7 @@ describe Ci::Build do context 'artifacts archive is a zip file and metadata exists' do let(:build) { create(:ci_build, :artifacts) } + it { is_expected.to be_truthy } end end @@ -1053,7 +1084,7 @@ describe Ci::Build do end describe 'state transition as a deployable' do - let!(:build) { create(:ci_build, :with_deployment, :start_review_app) } + let!(:build) { create(:ci_build, :with_deployment, :start_review_app, project: project, pipeline: pipeline) } let(:deployment) { build.deployment } let(:environment) { deployment.environment } @@ -1118,6 +1149,60 @@ describe Ci::Build do end end + describe 'state transition with resource group' do + let(:resource_group) { create(:ci_resource_group, project: project) } + + context 'when build status is created' do + let(:build) { create(:ci_build, :created, project: project, resource_group: resource_group) } + + it 'is waiting for resource when build is enqueued' do + expect(Ci::ResourceGroups::AssignResourceFromResourceGroupWorker).to receive(:perform_async).with(resource_group.id) + + expect { build.enqueue! }.to change { build.status }.from('created').to('waiting_for_resource') + + expect(build.waiting_for_resource_at).not_to be_nil + end + + context 'when build is waiting for resource' do + before do + build.update_column(:status, 'waiting_for_resource') + end + + it 'is enqueued when build requests resource' do + expect { build.enqueue_waiting_for_resource! }.to change { build.status }.from('waiting_for_resource').to('pending') + end + + it 'releases a resource when build finished' do + expect(build.resource_group).to receive(:release_resource_from).with(build).and_call_original + expect(Ci::ResourceGroups::AssignResourceFromResourceGroupWorker).to receive(:perform_async).with(build.resource_group_id) + + build.enqueue_waiting_for_resource! + build.success! + end + + context 'when build has prerequisites' do + before do + allow(build).to receive(:any_unmet_prerequisites?) { true } + end + + it 'is preparing when build is enqueued' do + expect { build.enqueue_waiting_for_resource! }.to change { build.status }.from('waiting_for_resource').to('preparing') + end + end + + context 'when there are no available resources' do + before do + resource_group.assign_resource_to(create(:ci_build)) + end + + it 'stays as waiting for resource when build requests resource' do + expect { build.enqueue_waiting_for_resource }.not_to change { build.status } + end + end + end + end + end + describe '#on_stop' do subject { build.on_stop } @@ -1408,6 +1493,7 @@ describe Ci::Build do describe '#erased?' do let!(:build) { create(:ci_build, :trace_artifact, :success, :artifacts) } + subject { build.erased? } context 'job has not been erased' do @@ -1469,6 +1555,7 @@ describe Ci::Build do describe '#first_pending' do let!(:first) { create(:ci_build, pipeline: pipeline, status: 'pending', created_at: Date.yesterday) } let!(:second) { create(:ci_build, pipeline: pipeline, status: 'pending') } + subject { described_class.first_pending } it { is_expected.to be_a(described_class) } @@ -1553,6 +1640,12 @@ describe Ci::Build do it { is_expected.to be_cancelable } end + + context 'when build is waiting for resource' do + let(:build) { create(:ci_build, :waiting_for_resource) } + + it { is_expected.to be_cancelable } + end end context 'when build is not cancelable' do @@ -2296,6 +2389,7 @@ describe Ci::Build do { key: 'CI_BUILD_STAGE', value: 'test', public: true, masked: false }, { key: 'CI', value: 'true', public: true, masked: false }, { key: 'GITLAB_CI', value: 'true', public: true, masked: false }, + { key: 'CI_SERVER_URL', value: Gitlab.config.gitlab.url, public: true, masked: false }, { key: 'CI_SERVER_HOST', value: Gitlab.config.gitlab.host, public: true, masked: false }, { key: 'CI_SERVER_NAME', value: 'GitLab', public: true, masked: false }, { key: 'CI_SERVER_VERSION', value: Gitlab::VERSION, public: true, masked: false }, @@ -3894,7 +3988,7 @@ describe Ci::Build do end context 'when build is a last deployment' do - let(:build) { create(:ci_build, :success, environment: 'production') } + let(:build) { create(:ci_build, :success, environment: 'production', pipeline: pipeline, project: project) } let(:environment) { create(:environment, name: 'production', project: build.project) } let!(:deployment) { create(:deployment, :success, environment: environment, project: environment.project, deployable: build) } @@ -3902,7 +3996,7 @@ describe Ci::Build do end context 'when there is a newer build with deployment' do - let(:build) { create(:ci_build, :success, environment: 'production') } + let(:build) { create(:ci_build, :success, environment: 'production', pipeline: pipeline, project: project) } let(:environment) { create(:environment, name: 'production', project: build.project) } let!(:deployment) { create(:deployment, :success, environment: environment, project: environment.project, deployable: build) } let!(:last_deployment) { create(:deployment, :success, environment: environment, project: environment.project) } @@ -3911,7 +4005,7 @@ describe Ci::Build do end context 'when build with deployment has failed' do - let(:build) { create(:ci_build, :failed, environment: 'production') } + let(:build) { create(:ci_build, :failed, environment: 'production', pipeline: pipeline, project: project) } let(:environment) { create(:environment, name: 'production', project: build.project) } let!(:deployment) { create(:deployment, :success, environment: environment, project: environment.project, deployable: build) } @@ -3919,7 +4013,7 @@ describe Ci::Build do end context 'when build with deployment is running' do - let(:build) { create(:ci_build, environment: 'production') } + let(:build) { create(:ci_build, environment: 'production', pipeline: pipeline, project: project) } let(:environment) { create(:environment, name: 'production', project: build.project) } let!(:deployment) { create(:deployment, :success, environment: environment, project: environment.project, deployable: build) } diff --git a/spec/models/ci/build_trace_chunk_spec.rb b/spec/models/ci/build_trace_chunk_spec.rb index 96d81f4cc495762468e5d455392df3ce00066d7d..69fd167e0c8763c16ab275a8716584282d90f193 100644 --- a/spec/models/ci/build_trace_chunk_spec.rb +++ b/spec/models/ci/build_trace_chunk_spec.rb @@ -604,7 +604,7 @@ describe Ci::BuildTraceChunk, :clean_gitlab_redis_shared_state do context 'when traces are archived' do let(:subject) do project.builds.each do |build| - build.success! + build.reset.success! end end diff --git a/spec/models/ci/pipeline_config_spec.rb b/spec/models/ci/pipeline_config_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..25f514ee5ab9c4251379487178bc21bba3e3028f --- /dev/null +++ b/spec/models/ci/pipeline_config_spec.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Ci::PipelineConfig, type: :model do + it { is_expected.to belong_to(:pipeline) } + + it { is_expected.to validate_presence_of(:pipeline) } + it { is_expected.to validate_presence_of(:content) } +end diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb index 3f9e882ea5239852a751384656e080d6d757eedc..013581c0d94dc5bed5c1b17cd91be801b9bf62b9 100644 --- a/spec/models/ci/pipeline_spec.rb +++ b/spec/models/ci/pipeline_spec.rb @@ -35,6 +35,7 @@ describe Ci::Pipeline, :mailer do it { is_expected.to have_one(:source_pipeline) } it { is_expected.to have_one(:triggered_by_pipeline) } it { is_expected.to have_one(:source_job) } + it { is_expected.to have_one(:pipeline_config) } it { is_expected.to validate_presence_of(:sha) } it { is_expected.to validate_presence_of(:status) } @@ -1007,22 +1008,22 @@ describe Ci::Pipeline, :mailer do end end - describe '#duration', :sidekiq_might_not_need_inline do + describe '#duration', :sidekiq_inline do context 'when multiple builds are finished' do before do travel_to(current + 30) do build.run! - build.success! + build.reload.success! build_b.run! build_c.run! end travel_to(current + 40) do - build_b.drop! + build_b.reload.drop! end travel_to(current + 70) do - build_c.success! + build_c.reload.success! end end @@ -1043,7 +1044,7 @@ describe Ci::Pipeline, :mailer do end travel_to(current + 5.minutes) do - build.success! + build.reload.success! end end @@ -1182,6 +1183,38 @@ describe Ci::Pipeline, :mailer do end end + describe 'auto devops pipeline metrics' do + using RSpec::Parameterized::TableSyntax + + let(:pipeline) { create(:ci_empty_pipeline, config_source: config_source) } + let(:config_source) { :auto_devops_source } + + where(:action, :status) do + :succeed | 'success' + :drop | 'failed' + :skip | 'skipped' + :cancel | 'canceled' + end + + with_them do + context "when pipeline receives action '#{params[:action]}'" do + subject { pipeline.public_send(action) } + + it { expect { subject }.to change { auto_devops_pipelines_completed_total(status) }.by(1) } + + context 'when not auto_devops_source?' do + let(:config_source) { :repository_source } + + it { expect { subject }.not_to change { auto_devops_pipelines_completed_total(status) } } + end + end + end + + def auto_devops_pipelines_completed_total(status) + Gitlab::Metrics.counter(:auto_devops_pipelines_completed_total, 'Number of completed auto devops pipelines').get(status: status) + end + end + def create_build(name, *traits, queued_at: current, started_from: 0, **opts) create(:ci_build, *traits, name: name, @@ -1552,6 +1585,30 @@ describe Ci::Pipeline, :mailer do end end + describe '#needs_processing?' do + using RSpec::Parameterized::TableSyntax + + subject { pipeline.needs_processing? } + + where(:processed, :result) do + nil | true + false | true + true | false + end + + with_them do + let(:build) do + create(:ci_build, :success, pipeline: pipeline, name: 'rubocop') + end + + before do + build.update_column(:processed, processed) + end + + it { is_expected.to eq(result) } + end + end + shared_context 'with some outdated pipelines' do before do create_pipeline(:canceled, 'ref', 'A', project) @@ -1749,16 +1806,27 @@ describe Ci::Pipeline, :mailer do subject { described_class.bridgeable_statuses } it { is_expected.to be_an(Array) } - it { is_expected.not_to include('created', 'preparing', 'pending') } + it { is_expected.not_to include('created', 'waiting_for_resource', 'preparing', 'pending') } end - describe '#status', :sidekiq_might_not_need_inline do + describe '#status', :sidekiq_inline do let(:build) do create(:ci_build, :created, pipeline: pipeline, name: 'test') end subject { pipeline.reload.status } + context 'on waiting for resource' do + before do + allow(build).to receive(:requires_resource?) { true } + allow(Ci::ResourceGroups::AssignResourceFromResourceGroupWorker).to receive(:perform_async) + + build.enqueue + end + + it { is_expected.to eq('waiting_for_resource') } + end + context 'on prepare' do before do # Prevent skipping directly to 'pending' @@ -1782,7 +1850,7 @@ describe Ci::Pipeline, :mailer do context 'on run' do before do build.enqueue - build.run + build.reload.run end it { is_expected.to eq('running') } @@ -1841,7 +1909,7 @@ describe Ci::Pipeline, :mailer do it 'updates does not change pipeline status' do expect(pipeline.statuses.latest.slow_composite_status).to be_nil - expect { pipeline.update_status } + expect { pipeline.update_legacy_status } .to change { pipeline.reload.status } .from('created') .to('skipped') @@ -1854,7 +1922,7 @@ describe Ci::Pipeline, :mailer do end it 'updates pipeline status to running' do - expect { pipeline.update_status } + expect { pipeline.update_legacy_status } .to change { pipeline.reload.status } .from('created') .to('running') @@ -1867,7 +1935,7 @@ describe Ci::Pipeline, :mailer do end it 'updates pipeline status to scheduled' do - expect { pipeline.update_status } + expect { pipeline.update_legacy_status } .to change { pipeline.reload.status } .from('created') .to('scheduled') @@ -1882,7 +1950,7 @@ describe Ci::Pipeline, :mailer do end it 'raises an exception' do - expect { pipeline.update_status } + expect { pipeline.update_legacy_status } .to raise_error(HasStatus::UnknownStatusError) end end @@ -2170,11 +2238,11 @@ describe Ci::Pipeline, :mailer do stub_full_request(hook.url, method: :post) end - context 'with multiple builds', :sidekiq_might_not_need_inline do + context 'with multiple builds', :sidekiq_inline do context 'when build is queued' do before do - build_a.enqueue - build_b.enqueue + build_a.reload.enqueue + build_b.reload.enqueue end it 'receives a pending event once' do @@ -2184,10 +2252,10 @@ describe Ci::Pipeline, :mailer do context 'when build is run' do before do - build_a.enqueue - build_a.run - build_b.enqueue - build_b.run + build_a.reload.enqueue + build_a.reload.run! + build_b.reload.enqueue + build_b.reload.run! end it 'receives a running event once' do @@ -2248,6 +2316,7 @@ describe Ci::Pipeline, :mailer do :created, pipeline: pipeline, name: name, + stage: "stage:#{stage_idx}", stage_idx: stage_idx) end end @@ -2704,4 +2773,114 @@ describe Ci::Pipeline, :mailer do end end end + + describe '#parent_pipeline' do + let(:project) { create(:project) } + let(:pipeline) { create(:ci_pipeline, project: project) } + + context 'when pipeline is triggered by a pipeline from the same project' do + let(:upstream_pipeline) { create(:ci_pipeline, project: pipeline.project) } + + before do + create(:ci_sources_pipeline, + source_pipeline: upstream_pipeline, + source_project: project, + pipeline: pipeline, + project: project) + end + + it 'returns the parent pipeline' do + expect(pipeline.parent_pipeline).to eq(upstream_pipeline) + end + + it 'is child' do + expect(pipeline).to be_child + end + end + + context 'when pipeline is triggered by a pipeline from another project' do + let(:upstream_pipeline) { create(:ci_pipeline) } + + before do + create(:ci_sources_pipeline, + source_pipeline: upstream_pipeline, + source_project: upstream_pipeline.project, + pipeline: pipeline, + project: project) + end + + it 'returns nil' do + expect(pipeline.parent_pipeline).to be_nil + end + + it 'is not child' do + expect(pipeline).not_to be_child + end + end + + context 'when pipeline is not triggered by a pipeline' do + it 'returns nil' do + expect(pipeline.parent_pipeline).to be_nil + end + + it 'is not child' do + expect(pipeline).not_to be_child + end + end + end + + describe '#child_pipelines' do + let(:project) { create(:project) } + let(:pipeline) { create(:ci_pipeline, project: project) } + + context 'when pipeline triggered other pipelines on same project' do + let(:downstream_pipeline) { create(:ci_pipeline, project: pipeline.project) } + + before do + create(:ci_sources_pipeline, + source_pipeline: pipeline, + source_project: pipeline.project, + pipeline: downstream_pipeline, + project: pipeline.project) + end + + it 'returns the child pipelines' do + expect(pipeline.child_pipelines).to eq [downstream_pipeline] + end + + it 'is parent' do + expect(pipeline).to be_parent + end + end + + context 'when pipeline triggered other pipelines on another project' do + let(:downstream_pipeline) { create(:ci_pipeline) } + + before do + create(:ci_sources_pipeline, + source_pipeline: pipeline, + source_project: pipeline.project, + pipeline: downstream_pipeline, + project: downstream_pipeline.project) + end + + it 'returns empty array' do + expect(pipeline.child_pipelines).to be_empty + end + + it 'is not parent' do + expect(pipeline).not_to be_parent + end + end + + context 'when pipeline did not trigger any pipelines' do + it 'returns empty array' do + expect(pipeline.child_pipelines).to be_empty + end + + it 'is not parent' do + expect(pipeline).not_to be_parent + end + end + end end diff --git a/spec/models/ci/processable_spec.rb b/spec/models/ci/processable_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..87dbcbf870ef482e03555773843c6abf7ef652d7 --- /dev/null +++ b/spec/models/ci/processable_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Ci::Processable do + set(:project) { create(:project) } + set(:pipeline) { create(:ci_pipeline, project: project) } + + describe '#aggregated_needs_names' do + let(:with_aggregated_needs) { pipeline.processables.select_with_aggregated_needs(project) } + + context 'with created status' do + let!(:processable) { create(:ci_build, :created, project: project, pipeline: pipeline) } + + context 'with needs' do + before do + create(:ci_build_need, build: processable, name: 'test1') + create(:ci_build_need, build: processable, name: 'test2') + end + + it 'returns all processables' do + expect(with_aggregated_needs).to contain_exactly(processable) + end + + it 'returns all needs' do + expect(with_aggregated_needs.first.aggregated_needs_names).to contain_exactly('test1', 'test2') + end + + context 'with ci_dag_support disabled' do + before do + stub_feature_flags(ci_dag_support: false) + end + + it 'returns all processables' do + expect(with_aggregated_needs).to contain_exactly(processable) + end + + it 'returns empty needs' do + expect(with_aggregated_needs.first.aggregated_needs_names).to be_nil + end + end + end + + context 'without needs' do + it 'returns all processables' do + expect(with_aggregated_needs).to contain_exactly(processable) + end + + it 'returns empty needs' do + expect(with_aggregated_needs.first.aggregated_needs_names).to be_nil + end + end + end + end +end diff --git a/spec/models/ci/resource_group_spec.rb b/spec/models/ci/resource_group_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..ce8b03282bcf446b91e7bbc4b6beff616c60fec2 --- /dev/null +++ b/spec/models/ci/resource_group_spec.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Ci::ResourceGroup do + describe 'validation' do + it 'valids when key includes allowed character' do + resource_group = build(:ci_resource_group, key: 'test') + + expect(resource_group).to be_valid + end + + it 'invalids when key includes invalid character' do + resource_group = build(:ci_resource_group, key: ':::') + + expect(resource_group).not_to be_valid + end + end + + describe '#ensure_resource' do + it 'creates one resource when resource group is created' do + resource_group = create(:ci_resource_group) + + expect(resource_group.resources.count).to eq(1) + expect(resource_group.resources.all?(&:persisted?)).to eq(true) + end + end + + describe '#assign_resource_to' do + subject { resource_group.assign_resource_to(build) } + + let(:build) { create(:ci_build) } + let(:resource_group) { create(:ci_resource_group) } + + it 'retains resource for the build' do + expect(resource_group.resources.first.build).to be_nil + + is_expected.to eq(true) + + expect(resource_group.resources.first.build).to eq(build) + end + + context 'when there are no free resources' do + before do + resource_group.assign_resource_to(create(:ci_build)) + end + + it 'fails to retain resource' do + is_expected.to eq(false) + end + end + + context 'when the build has already retained a resource' do + let!(:another_resource) { create(:ci_resource, resource_group: resource_group, build: build) } + + it 'fails to retain resource' do + expect { subject }.to raise_error(ActiveRecord::RecordNotUnique) + end + end + end + + describe '#release_resource_from' do + subject { resource_group.release_resource_from(build) } + + let(:build) { create(:ci_build) } + let(:resource_group) { create(:ci_resource_group) } + + context 'when the build has already retained a resource' do + before do + resource_group.assign_resource_to(build) + end + + it 'releases resource from the build' do + expect(resource_group.resources.first.build).to eq(build) + + is_expected.to eq(true) + + expect(resource_group.resources.first.build).to be_nil + end + end + + context 'when the build has already released a resource' do + it 'fails to release resource' do + is_expected.to eq(false) + end + end + end +end diff --git a/spec/models/ci/resource_spec.rb b/spec/models/ci/resource_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..27e512e2c454b7811c0a90d434328b513c84a8d2 --- /dev/null +++ b/spec/models/ci/resource_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Ci::Resource do + describe '.free' do + subject { described_class.free } + + let(:resource_group) { create(:ci_resource_group) } + let!(:free_resource) { resource_group.resources.take } + let!(:retained_resource) { create(:ci_resource, :retained, resource_group: resource_group) } + + it 'returns free resources' do + is_expected.to eq([free_resource]) + end + end + + describe '.retained_by' do + subject { described_class.retained_by(build) } + + let(:build) { create(:ci_build) } + let!(:resource) { create(:ci_resource, build: build) } + + it 'returns retained resources' do + is_expected.to eq([resource]) + end + end +end diff --git a/spec/models/ci/runner_spec.rb b/spec/models/ci/runner_spec.rb index ac438f7d4739a16a3109a544205ee95ff3118835..5c9a03a26ec980c1358724e157cf3a14fe56530e 100644 --- a/spec/models/ci/runner_spec.rb +++ b/spec/models/ci/runner_spec.rb @@ -755,11 +755,13 @@ describe Ci::Runner do context 'when group runner' do let(:runner) { create(:ci_runner, :group, description: 'Group runner', groups: [group]) } let(:group) { create(:group) } + it { is_expected.to be_falsey } end context 'when shared runner' do let(:runner) { create(:ci_runner, :instance, description: 'Shared runner') } + it { is_expected.to be_falsey } end diff --git a/spec/models/ci/stage_spec.rb b/spec/models/ci/stage_spec.rb index c997f1ef40553522c01d879c202aa19980d6b65c..3aeaa27abce3228cabe15f6d56dc0b6aedf48450 100644 --- a/spec/models/ci/stage_spec.rb +++ b/spec/models/ci/stage_spec.rb @@ -63,7 +63,7 @@ describe Ci::Stage, :models do end it 'updates stage status correctly' do - expect { stage.update_status } + expect { stage.update_legacy_status } .to change { stage.reload.status } .to eq 'running' end @@ -87,7 +87,7 @@ describe Ci::Stage, :models do end it 'updates status to skipped' do - expect { stage.update_status } + expect { stage.update_legacy_status } .to change { stage.reload.status } .to eq 'skipped' end @@ -99,15 +99,27 @@ describe Ci::Stage, :models do end it 'updates status to scheduled' do - expect { stage.update_status } + expect { stage.update_legacy_status } .to change { stage.reload.status } .to 'scheduled' end end + context 'when build is waiting for resource' do + before do + create(:ci_build, :waiting_for_resource, stage_id: stage.id) + end + + it 'updates status to waiting for resource' do + expect { stage.update_legacy_status } + .to change { stage.reload.status } + .to 'waiting_for_resource' + end + end + context 'when stage is skipped because is empty' do it 'updates status to skipped' do - expect { stage.update_status } + expect { stage.update_legacy_status } .to change { stage.reload.status } .to eq('skipped') end @@ -121,7 +133,7 @@ describe Ci::Stage, :models do it 'retries a lock to update a stage status' do stage.lock_version = 100 - stage.update_status + stage.update_legacy_status expect(stage.reload).to be_failed end @@ -135,7 +147,7 @@ describe Ci::Stage, :models do end it 'raises an exception' do - expect { stage.update_status } + expect { stage.update_legacy_status } .to raise_error(HasStatus::UnknownStatusError) end end @@ -146,6 +158,7 @@ describe Ci::Stage, :models do let(:user) { create(:user) } let(:stage) { create(:ci_stage_entity, status: :created) } + subject { stage.detailed_status(user) } where(:statuses, :label) do @@ -166,7 +179,7 @@ describe Ci::Stage, :models do stage_id: stage.id, status: status) - stage.update_status + stage.update_legacy_status end end @@ -183,7 +196,7 @@ describe Ci::Stage, :models do status: :failed, allow_failure: true) - stage.update_status + stage.update_legacy_status end it 'is passed with warnings' do @@ -230,7 +243,7 @@ describe Ci::Stage, :models do it 'recalculates index before updating status' do expect(stage.reload.position).to be_nil - stage.update_status + stage.update_legacy_status expect(stage.reload.position).to eq 10 end @@ -240,7 +253,7 @@ describe Ci::Stage, :models do it 'fallbacks to zero' do expect(stage.reload.position).to be_nil - stage.update_status + stage.update_legacy_status expect(stage.reload.position).to eq 0 end diff --git a/spec/models/ci/trigger_spec.rb b/spec/models/ci/trigger_spec.rb index 5b5d6f51b33401eab5e05986d10a6c922ab17a43..5b0815f815621c927b6e5186a13fd244d6592dfb 100644 --- a/spec/models/ci/trigger_spec.rb +++ b/spec/models/ci/trigger_spec.rb @@ -11,6 +11,10 @@ describe Ci::Trigger do it { is_expected.to have_many(:trigger_requests) } end + describe 'validations' do + it { is_expected.to validate_presence_of(:owner) } + end + describe 'before_validation' do it 'sets an random token if none provided' do trigger = create(:ci_trigger_without_token, project: project) @@ -35,63 +39,22 @@ describe Ci::Trigger do end end - describe '#legacy?' do - let(:trigger) { create(:ci_trigger, owner: owner, project: project) } - - subject { trigger } - - context 'when owner is blank' do - let(:owner) { nil } - - it { is_expected.to be_legacy } - end - - context 'when owner is set' do - let(:owner) { create(:user) } - - it { is_expected.not_to be_legacy } - end - end - describe '#can_access_project?' do let(:owner) { create(:user) } let(:trigger) { create(:ci_trigger, owner: owner, project: project) } - context 'when owner is blank' do + subject { trigger.can_access_project? } + + context 'and is member of the project' do before do - stub_feature_flags(use_legacy_pipeline_triggers: false) - trigger.update_attribute(:owner, nil) + project.add_developer(owner) end - subject { trigger.can_access_project? } - - it { is_expected.to eq(false) } - - context 'when :use_legacy_pipeline_triggers feature flag is enabled' do - before do - stub_feature_flags(use_legacy_pipeline_triggers: true) - end - - subject { trigger.can_access_project? } - - it { is_expected.to eq(true) } - end + it { is_expected.to eq(true) } end - context 'when owner is set' do - subject { trigger.can_access_project? } - - context 'and is member of the project' do - before do - project.add_developer(owner) - end - - it { is_expected.to eq(true) } - end - - context 'and is not member of the project' do - it { is_expected.to eq(false) } - end + context 'and is not member of the project' do + it { is_expected.to eq(false) } end end end diff --git a/spec/models/clusters/applications/elastic_stack_spec.rb b/spec/models/clusters/applications/elastic_stack_spec.rb index d0e0dd5ad57a099c4ffe1a603d8050c0e85486d6..d336dc752c821ab7813818b491daf504f242b141 100644 --- a/spec/models/clusters/applications/elastic_stack_spec.rb +++ b/spec/models/clusters/applications/elastic_stack_spec.rb @@ -10,45 +10,8 @@ describe Clusters::Applications::ElasticStack do include_examples 'cluster application version specs', :clusters_applications_elastic_stack include_examples 'cluster application helm specs', :clusters_applications_elastic_stack - describe '#can_uninstall?' do - let(:ingress) { create(:clusters_applications_ingress, :installed, external_hostname: 'localhost.localdomain') } - let(:elastic_stack) { create(:clusters_applications_elastic_stack, cluster: ingress.cluster) } - - subject { elastic_stack.can_uninstall? } - - it { is_expected.to be_truthy } - end - - describe '#set_initial_status' do - before do - elastic_stack.set_initial_status - end - - context 'when ingress is not installed' do - let(:cluster) { create(:cluster, :provided_by_gcp) } - let(:elastic_stack) { create(:clusters_applications_elastic_stack, cluster: cluster) } - - it { expect(elastic_stack).to be_not_installable } - end - - context 'when ingress is installed and external_ip is assigned' do - let(:ingress) { create(:clusters_applications_ingress, :installed, external_ip: '127.0.0.1') } - let(:elastic_stack) { create(:clusters_applications_elastic_stack, cluster: ingress.cluster) } - - it { expect(elastic_stack).to be_installable } - end - - context 'when ingress is installed and external_hostname is assigned' do - let(:ingress) { create(:clusters_applications_ingress, :installed, external_hostname: 'localhost.localdomain') } - let(:elastic_stack) { create(:clusters_applications_elastic_stack, cluster: ingress.cluster) } - - it { expect(elastic_stack).to be_installable } - end - end - describe '#install_command' do - let!(:ingress) { create(:clusters_applications_ingress, :installed, external_ip: '127.0.0.1') } - let!(:elastic_stack) { create(:clusters_applications_elastic_stack, cluster: ingress.cluster) } + let!(:elastic_stack) { create(:clusters_applications_elastic_stack) } subject { elastic_stack.install_command } @@ -80,8 +43,7 @@ describe Clusters::Applications::ElasticStack do end describe '#uninstall_command' do - let!(:ingress) { create(:clusters_applications_ingress, :installed, external_ip: '127.0.0.1') } - let!(:elastic_stack) { create(:clusters_applications_elastic_stack, cluster: ingress.cluster) } + let!(:elastic_stack) { create(:clusters_applications_elastic_stack) } subject { elastic_stack.uninstall_command } @@ -100,19 +62,6 @@ describe Clusters::Applications::ElasticStack do end end - describe '#files' do - let!(:ingress) { create(:clusters_applications_ingress, :installed, external_ip: '127.0.0.1') } - let!(:elastic_stack) { create(:clusters_applications_elastic_stack, cluster: ingress.cluster) } - - let(:values) { subject[:'values.yaml'] } - - subject { elastic_stack.files } - - it 'includes elastic stack specific keys in the values.yaml file' do - expect(values).to include('ELASTICSEARCH_HOSTS') - end - end - describe '#elasticsearch_client' do context 'cluster is nil' do it 'returns nil' do @@ -123,6 +72,7 @@ describe Clusters::Applications::ElasticStack do context "cluster doesn't have kubeclient" do let(:cluster) { create(:cluster) } + subject { create(:clusters_applications_elastic_stack, cluster: cluster) } it 'returns nil' do diff --git a/spec/models/clusters/applications/helm_spec.rb b/spec/models/clusters/applications/helm_spec.rb index 64f58155a66e0cdada7edc7077404247b6c4a16d..87454e1d3e207b01f0c73d8ce30a3e8b46b1074e 100644 --- a/spec/models/clusters/applications/helm_spec.rb +++ b/spec/models/clusters/applications/helm_spec.rb @@ -52,6 +52,7 @@ describe Clusters::Applications::Helm do describe '#issue_client_cert' do let(:application) { create(:clusters_applications_helm) } + subject { application.issue_client_cert } it 'returns a new cert' do diff --git a/spec/models/clusters/applications/ingress_spec.rb b/spec/models/clusters/applications/ingress_spec.rb index d7ad7867e1a502af3e940c0600f73dc8586ee830..c115869860152f8ababa89e27efd734b90214208 100644 --- a/spec/models/clusters/applications/ingress_spec.rb +++ b/spec/models/clusters/applications/ingress_spec.rb @@ -142,11 +142,11 @@ describe Clusters::Applications::Ingress do let(:project) { build(:project) } let(:cluster) { build(:cluster, projects: [project]) } - context 'when ingress_modsecurity is enabled' do + context 'when modsecurity_enabled is enabled' do before do - stub_feature_flags(ingress_modsecurity: true) - allow(subject).to receive(:cluster).and_return(cluster) + + allow(subject).to receive(:modsecurity_enabled).and_return(true) end it 'includes modsecurity module enablement' do @@ -173,10 +173,8 @@ describe Clusters::Applications::Ingress do end end - context 'when ingress_modsecurity is disabled' do + context 'when modsecurity_enabled is disabled' do before do - stub_feature_flags(ingress_modsecurity: false) - allow(subject).to receive(:cluster).and_return(cluster) end diff --git a/spec/models/clusters/applications/jupyter_spec.rb b/spec/models/clusters/applications/jupyter_spec.rb index 0ec9333d6a7712094a14e5747e56a70de6ae5cf2..3bc5088d1ab4f5f01be4efbb108f58fe57e8cd98 100644 --- a/spec/models/clusters/applications/jupyter_spec.rb +++ b/spec/models/clusters/applications/jupyter_spec.rb @@ -57,7 +57,8 @@ describe Clusters::Applications::Jupyter do it 'is initialized with 4 arguments' do expect(subject.name).to eq('jupyter') expect(subject.chart).to eq('jupyter/jupyterhub') - expect(subject.version).to eq('0.9-174bbd5') + expect(subject.version).to eq('0.9.0-beta.2') + expect(subject).to be_rbac expect(subject.repository).to eq('https://jupyterhub.github.io/helm-chart/') expect(subject.files).to eq(jupyter.files) @@ -75,7 +76,7 @@ describe Clusters::Applications::Jupyter do let(:jupyter) { create(:clusters_applications_jupyter, :errored, version: '0.0.1') } it 'is initialized with the locked version' do - expect(subject.version).to eq('0.9-174bbd5') + expect(subject.version).to eq('0.9.0-beta.2') end end end diff --git a/spec/models/clusters/applications/knative_spec.rb b/spec/models/clusters/applications/knative_spec.rb index c1057af5f80a87cddae8f0352faca38b127c3cc9..68ac3f0d483d321a7016d501dff7995b1a8b77be 100644 --- a/spec/models/clusters/applications/knative_spec.rb +++ b/spec/models/clusters/applications/knative_spec.rb @@ -131,6 +131,7 @@ describe Clusters::Applications::Knative do describe '#update_command' do let!(:current_installed_version) { knative.version = '0.1.0' } + subject { knative.update_command } it 'is initialized with current version' do diff --git a/spec/models/clusters/applications/prometheus_spec.rb b/spec/models/clusters/applications/prometheus_spec.rb index d588ce3bc382c7b5af50405adac597db13bf6731..cf33d2b4273f33ec3fce208df09de666ae029ac5 100644 --- a/spec/models/clusters/applications/prometheus_spec.rb +++ b/spec/models/clusters/applications/prometheus_spec.rb @@ -66,6 +66,7 @@ describe Clusters::Applications::Prometheus do context "cluster doesn't have kubeclient" do let(:cluster) { create(:cluster) } + subject { create(:clusters_applications_prometheus, cluster: cluster) } it 'returns nil' do @@ -116,6 +117,12 @@ describe Clusters::Applications::Prometheus do let(:exception) { Errno::ECONNRESET } end end + + context 'when the network is unreachable' do + it_behaves_like 'exception caught for prometheus client' do + let(:exception) { Errno::ENETUNREACH } + end + end end end @@ -129,7 +136,7 @@ describe Clusters::Applications::Prometheus do it 'is initialized with 3 arguments' do expect(subject.name).to eq('prometheus') expect(subject.chart).to eq('stable/prometheus') - expect(subject.version).to eq('6.7.3') + expect(subject.version).to eq('9.5.2') expect(subject).to be_rbac expect(subject.files).to eq(prometheus.files) end @@ -146,7 +153,7 @@ describe Clusters::Applications::Prometheus do let(:prometheus) { create(:clusters_applications_prometheus, :errored, version: '2.0.0') } it 'is initialized with the locked version' do - expect(subject.version).to eq('6.7.3') + expect(subject.version).to eq('9.5.2') end end @@ -217,7 +224,7 @@ describe Clusters::Applications::Prometheus do it 'is initialized with 3 arguments' do expect(patch_command.name).to eq('prometheus') expect(patch_command.chart).to eq('stable/prometheus') - expect(patch_command.version).to eq('6.7.3') + expect(patch_command.version).to eq('9.5.2') expect(patch_command.files).to eq(prometheus.files) end end diff --git a/spec/models/commit_spec.rb b/spec/models/commit_spec.rb index 1c1b550c69b05d28ca1b51e4055c986667d0f26c..782d1ac45526666c3a25ec7a6791acaa733ac224 100644 --- a/spec/models/commit_spec.rb +++ b/spec/models/commit_spec.rb @@ -63,6 +63,20 @@ describe Commit do end end + describe '#diff_refs' do + it 'is equal to itself' do + expect(commit.diff_refs).to eq(commit.diff_refs) + end + + context 'from a factory' do + let(:commit) { create(:commit) } + + it 'is equal to itself' do + expect(commit.diff_refs).to eq(commit.diff_refs) + end + end + end + describe '#author', :request_store do it 'looks up the author in a case-insensitive way' do user = create(:user, email: commit.author_email.upcase) @@ -263,7 +277,7 @@ describe Commit do describe '#title' do it "returns no_commit_message when safe_message is blank" do allow(commit).to receive(:safe_message).and_return('') - expect(commit.title).to eq("--no commit message") + expect(commit.title).to eq("No commit message") end it 'truncates a message without a newline at natural break to 80 characters' do @@ -294,7 +308,7 @@ eos describe '#full_title' do it "returns no_commit_message when safe_message is blank" do allow(commit).to receive(:safe_message).and_return('') - expect(commit.full_title).to eq("--no commit message") + expect(commit.full_title).to eq("No commit message") end it "returns entire message if there is no newline" do @@ -316,7 +330,7 @@ eos it 'returns no_commit_message when safe_message is blank' do allow(commit).to receive(:safe_message).and_return(nil) - expect(commit.description).to eq('--no commit message') + expect(commit.description).to eq('No commit message') end it 'returns description of commit message if title less than 100 characters' do @@ -376,6 +390,17 @@ eos expect(commit.closes_issues).to include(issue) expect(commit.closes_issues).to include(other_issue) end + + it 'ignores referenced issues when auto-close is disabled' do + project.update!(autoclose_referenced_issues: false) + + allow(commit).to receive_messages( + safe_message: "Fixes ##{issue.iid}", + committer_email: committer.email + ) + + expect(commit.closes_issues).to be_empty + end end it_behaves_like 'a mentionable' do diff --git a/spec/models/commit_status_spec.rb b/spec/models/commit_status_spec.rb index 31aebac54e1ba0213b8f85a2d23df26bbcc6fb93..406526141016efc3e527a207dc6caf259302c6fe 100644 --- a/spec/models/commit_status_spec.rb +++ b/spec/models/commit_status_spec.rb @@ -63,6 +63,42 @@ describe CommitStatus do end end + describe '#processed' do + subject { commit_status.processed } + + context 'when ci_atomic_processing is disabled' do + before do + stub_feature_flags(ci_atomic_processing: false) + + commit_status.save! + end + + it { is_expected.to be_nil } + end + + context 'when ci_atomic_processing is enabled' do + before do + stub_feature_flags(ci_atomic_processing: true) + end + + context 'status is latest' do + before do + commit_status.update!(retried: false, status: :pending) + end + + it { is_expected.to be_falsey } + end + + context 'status is retried' do + before do + commit_status.update!(retried: true, status: :pending) + end + + it { is_expected.to be_truthy } + end + end + end + describe '#started?' do subject { commit_status.started? } @@ -634,6 +670,30 @@ describe CommitStatus do end end + describe '#all_met_to_become_pending?' do + subject { commit_status.all_met_to_become_pending? } + + let(:commit_status) { create(:commit_status) } + + it { is_expected.to eq(true) } + + context 'when build requires a resource' do + before do + allow(commit_status).to receive(:requires_resource?) { true } + end + + it { is_expected.to eq(false) } + end + + context 'when build has a prerequisite' do + before do + allow(commit_status).to receive(:any_unmet_prerequisites?) { true } + end + + it { is_expected.to eq(false) } + end + end + describe '#enqueue' do let!(:current_time) { Time.new(2018, 4, 5, 14, 0, 0) } @@ -654,12 +714,6 @@ describe CommitStatus do it_behaves_like 'commit status enqueued' end - context 'when initial state is :preparing' do - let(:commit_status) { create(:commit_status, :preparing) } - - it_behaves_like 'commit status enqueued' - end - context 'when initial state is :skipped' do let(:commit_status) { create(:commit_status, :skipped) } diff --git a/spec/models/concerns/atomic_internal_id_spec.rb b/spec/models/concerns/atomic_internal_id_spec.rb index 0605392c0aaddeb0b645a149d5fa0d5570e24184..93bf7ec10dde2e79ad900113c6e52d39a2ef071d 100644 --- a/spec/models/concerns/atomic_internal_id_spec.rb +++ b/spec/models/concerns/atomic_internal_id_spec.rb @@ -9,6 +9,32 @@ describe AtomicInternalId do let(:scope_attrs) { { project: milestone.project } } let(:usage) { :milestones } + describe '#save!' do + context 'when IID is provided' do + before do + milestone.iid = external_iid + end + + it 'tracks the value' do + expect(milestone).to receive(:track_project_iid!) + + milestone.save! + end + + context 'when importing' do + before do + milestone.importing = true + end + + it 'does not track the value' do + expect(milestone).not_to receive(:track_project_iid!) + + milestone.save! + end + end + end + end + describe '#track_project_iid!' do subject { milestone.track_project_iid! } diff --git a/spec/models/concerns/cache_markdown_field_spec.rb b/spec/models/concerns/cache_markdown_field_spec.rb index 9a12c3d696584dffc399f03ea494550c53e4af81..06d12c14793deee647eb5f015472e490498e473b 100644 --- a/spec/models/concerns/cache_markdown_field_spec.rb +++ b/spec/models/concerns/cache_markdown_field_spec.rb @@ -92,6 +92,7 @@ describe CacheMarkdownField, :clean_gitlab_redis_cache do describe '#latest_cached_markdown_version' do let(:thing) { klass.new } + subject { thing.latest_cached_markdown_version } it 'returns default version' do @@ -151,6 +152,7 @@ describe CacheMarkdownField, :clean_gitlab_redis_cache do describe '#banzai_render_context' do let(:thing) { klass.new(title: markdown, title_html: html, cached_markdown_version: cache_version) } + subject(:context) { thing.banzai_render_context(:title) } it 'sets project to nil if the object lacks a project' do diff --git a/spec/models/concerns/each_batch_spec.rb b/spec/models/concerns/each_batch_spec.rb index c4cf8e80f7afe376a8854d27324c679b9a84c1a8..294fde4f8e6b25fd51bbcb8fe4445c02c4b5c819 100644 --- a/spec/models/concerns/each_batch_spec.rb +++ b/spec/models/concerns/each_batch_spec.rb @@ -13,7 +13,7 @@ describe EachBatch do end before do - 5.times { create(:user, updated_at: 1.day.ago) } + create_list(:user, 5, updated_at: 1.day.ago) end shared_examples 'each_batch handling' do |kwargs| diff --git a/spec/models/concerns/has_status_spec.rb b/spec/models/concerns/has_status_spec.rb index 21e4dda6dabda10f1f837d13d925c46f0863b6ed..99d09af80d00ed4b33285a5e7020face3e668a66 100644 --- a/spec/models/concerns/has_status_spec.rb +++ b/spec/models/concerns/has_status_spec.rb @@ -39,6 +39,22 @@ describe HasStatus do it { is_expected.to eq 'running' } end + context 'all waiting for resource' do + let!(:statuses) do + [create(type, status: :waiting_for_resource), create(type, status: :waiting_for_resource)] + end + + it { is_expected.to eq 'waiting_for_resource' } + end + + context 'at least one waiting for resource' do + let!(:statuses) do + [create(type, status: :success), create(type, status: :waiting_for_resource)] + end + + it { is_expected.to eq 'waiting_for_resource' } + end + context 'all preparing' do let!(:statuses) do [create(type, status: :preparing), create(type, status: :preparing)] @@ -219,7 +235,7 @@ describe HasStatus do end end - %i[created preparing running pending success + %i[created waiting_for_resource preparing running pending success failed canceled skipped].each do |status| it_behaves_like 'having a job', status end @@ -265,7 +281,7 @@ describe HasStatus do describe '.alive' do subject { CommitStatus.alive } - %i[running pending preparing created].each do |status| + %i[running pending waiting_for_resource preparing created].each do |status| it_behaves_like 'containing the job', status end @@ -277,7 +293,7 @@ describe HasStatus do describe '.alive_or_scheduled' do subject { CommitStatus.alive_or_scheduled } - %i[running pending preparing created scheduled].each do |status| + %i[running pending waiting_for_resource preparing created scheduled].each do |status| it_behaves_like 'containing the job', status end @@ -313,7 +329,7 @@ describe HasStatus do describe '.cancelable' do subject { CommitStatus.cancelable } - %i[running pending preparing created scheduled].each do |status| + %i[running pending waiting_for_resource preparing created scheduled].each do |status| it_behaves_like 'containing the job', status end diff --git a/spec/models/concerns/ignorable_columns_spec.rb b/spec/models/concerns/ignorable_columns_spec.rb index 55efa1b5fdadb418919a62bdef9a51c100c97604..018b1296c62990fb8ae62aaa466c4ad5cc6455ea 100644 --- a/spec/models/concerns/ignorable_columns_spec.rb +++ b/spec/models/concerns/ignorable_columns_spec.rb @@ -49,11 +49,13 @@ describe IgnorableColumns do context 'with single column' do let(:columns) { :name } + it_behaves_like 'storing removal information' end context 'with array column' do let(:columns) { %i[name created_at] } + it_behaves_like 'storing removal information' end diff --git a/spec/models/concerns/issuable_spec.rb b/spec/models/concerns/issuable_spec.rb index 76a3a82597869918268abc637b4f122bdb534886..3e5c16c249195531f9db80ad35c226a608f8e681 100644 --- a/spec/models/concerns/issuable_spec.rb +++ b/spec/models/concerns/issuable_spec.rb @@ -53,43 +53,6 @@ describe Issuable do it_behaves_like 'validates description length with custom validation' it_behaves_like 'truncates the description to its allowed maximum length on import' end - - describe 'milestone' do - let(:project) { create(:project) } - let(:milestone_id) { create(:milestone, project: project).id } - let(:params) do - { - title: 'something', - project: project, - author: build(:user), - milestone_id: milestone_id - } - end - - subject { issuable_class.new(params) } - - context 'with correct params' do - it { is_expected.to be_valid } - end - - context 'with empty string milestone' do - let(:milestone_id) { '' } - - it { is_expected.to be_valid } - end - - context 'with nil milestone id' do - let(:milestone_id) { nil } - - it { is_expected.to be_valid } - end - - context 'with a milestone id from another project' do - let(:milestone_id) { create(:milestone).id } - - it { is_expected.to be_invalid } - end - end end describe "Scope" do @@ -141,48 +104,6 @@ describe Issuable do end end - describe '#milestone_available?' do - let(:group) { create(:group) } - let(:project) { create(:project, group: group) } - let(:issue) { create(:issue, project: project) } - - def build_issuable(milestone_id) - issuable_class.new(project: project, milestone_id: milestone_id) - end - - it 'returns true with a milestone from the issue project' do - milestone = create(:milestone, project: project) - - expect(build_issuable(milestone.id).milestone_available?).to be_truthy - end - - it 'returns true with a milestone from the issue project group' do - milestone = create(:milestone, group: group) - - expect(build_issuable(milestone.id).milestone_available?).to be_truthy - end - - it 'returns true with a milestone from the the parent of the issue project group' do - parent = create(:group) - group.update(parent: parent) - milestone = create(:milestone, group: parent) - - expect(build_issuable(milestone.id).milestone_available?).to be_truthy - end - - it 'returns false with a milestone from another project' do - milestone = create(:milestone) - - expect(build_issuable(milestone.id).milestone_available?).to be_falsey - end - - it 'returns false with a milestone from another group' do - milestone = create(:milestone, group: create(:group)) - - expect(build_issuable(milestone.id).milestone_available?).to be_falsey - end - end - describe ".search" do let!(:searchable_issue) { create(:issue, title: "Searchable awesome issue") } let!(:searchable_issue2) { create(:issue, title: 'Aw') } @@ -405,7 +326,7 @@ describe Issuable do context 'when all of the results are level on the sort key' do let!(:issues) do - 10.times { create(:issue, project: project) } + create_list(:issue, 10, project: project) end it 'has no duplicates across pages' do @@ -809,27 +730,6 @@ describe Issuable do end end - describe '#supports_milestone?' do - let(:group) { create(:group) } - let(:project) { create(:project, group: group) } - - context "for issues" do - let(:issue) { build(:issue, project: project) } - - it 'returns true' do - expect(issue.supports_milestone?).to be_truthy - end - end - - context "for merge requests" do - let(:merge_request) { build(:merge_request, target_project: project, source_project: project) } - - it 'returns true' do - expect(merge_request.supports_milestone?).to be_truthy - end - end - end - describe '#matches_cross_reference_regex?' do context "issue description with long path string" do let(:mentionable) { build(:issue, description: "/a" * 50000) } @@ -854,91 +754,4 @@ describe Issuable do it_behaves_like 'matches_cross_reference_regex? fails fast' end end - - describe 'release scopes' do - let_it_be(:project) { create(:project) } - let(:forked_project) { fork_project(project) } - - let_it_be(:release_1) { create(:release, tag: 'v1.0', project: project) } - let_it_be(:release_2) { create(:release, tag: 'v2.0', project: project) } - let_it_be(:release_3) { create(:release, tag: 'v3.0', project: project) } - let_it_be(:release_4) { create(:release, tag: 'v4.0', project: project) } - - let_it_be(:milestone_1) { create(:milestone, releases: [release_1], title: 'm1', project: project) } - let_it_be(:milestone_2) { create(:milestone, releases: [release_1, release_2], title: 'm2', project: project) } - let_it_be(:milestone_3) { create(:milestone, releases: [release_2, release_4], title: 'm3', project: project) } - let_it_be(:milestone_4) { create(:milestone, releases: [release_3], title: 'm4', project: project) } - let_it_be(:milestone_5) { create(:milestone, releases: [release_3], title: 'm5', project: project) } - let_it_be(:milestone_6) { create(:milestone, title: 'm6', project: project) } - - let_it_be(:issue_1) { create(:issue, milestone: milestone_1, project: project) } - let_it_be(:issue_2) { create(:issue, milestone: milestone_1, project: project) } - let_it_be(:issue_3) { create(:issue, milestone: milestone_2, project: project) } - let_it_be(:issue_4) { create(:issue, milestone: milestone_5, project: project) } - let_it_be(:issue_5) { create(:issue, milestone: milestone_6, project: project) } - let_it_be(:issue_6) { create(:issue, project: project) } - - let(:mr_1) { create(:merge_request, milestone: milestone_1, target_project: project, source_project: project) } - let(:mr_2) { create(:merge_request, milestone: milestone_3, target_project: project, source_project: forked_project) } - let(:mr_3) { create(:merge_request, source_project: project) } - - let_it_be(:issue_items) { Issue.all } - let(:mr_items) { MergeRequest.all } - - describe '#without_release' do - it 'returns the issues or mrs not tied to any milestone and the ones tied to milestone with no release' do - expect(issue_items.without_release).to contain_exactly(issue_5, issue_6) - expect(mr_items.without_release).to contain_exactly(mr_3) - end - end - - describe '#any_release' do - it 'returns all issues or all mrs tied to a release' do - expect(issue_items.any_release).to contain_exactly(issue_1, issue_2, issue_3, issue_4) - expect(mr_items.any_release).to contain_exactly(mr_1, mr_2) - end - end - - describe '#with_release' do - it 'returns the issues tied to a specfic release' do - expect(issue_items.with_release('v1.0', project.id)).to contain_exactly(issue_1, issue_2, issue_3) - end - - it 'returns the mrs tied to a specific release' do - expect(mr_items.with_release('v1.0', project.id)).to contain_exactly(mr_1) - end - - context 'when a release has a milestone with one issue and another one with no issue' do - it 'returns that one issue' do - expect(issue_items.with_release('v2.0', project.id)).to contain_exactly(issue_3) - end - - context 'when the milestone with no issue is added as a filter' do - it 'returns an empty list' do - expect(issue_items.with_release('v2.0', project.id).with_milestone('m3')).to be_empty - end - end - - context 'when the milestone with the issue is added as a filter' do - it 'returns this issue' do - expect(issue_items.with_release('v2.0', project.id).with_milestone('m2')).to contain_exactly(issue_3) - end - end - end - - context 'when there is no issue or mr under a specific release' do - it 'returns no issue or no mr' do - expect(issue_items.with_release('v4.0', project.id)).to be_empty - expect(mr_items.with_release('v4.0', project.id)).to be_empty - end - end - - context 'when a non-existent release tag is passed in' do - it 'returns no issue or no mr' do - expect(issue_items.with_release('v999.0', project.id)).to be_empty - expect(mr_items.with_release('v999.0', project.id)).to be_empty - end - end - end - end end diff --git a/spec/models/concerns/loaded_in_group_list_spec.rb b/spec/models/concerns/loaded_in_group_list_spec.rb index 7c97b58077953b6de98c5ee5c04d753c788142ff..509811822e0e89ce4a536b30ff174d7d8bd9d0fa 100644 --- a/spec/models/concerns/loaded_in_group_list_spec.rb +++ b/spec/models/concerns/loaded_in_group_list_spec.rb @@ -4,6 +4,7 @@ require 'spec_helper' describe LoadedInGroupList do let(:parent) { create(:group) } + subject(:found_group) { Group.with_selects_for_list.find_by(id: parent.id) } describe '.with_selects_for_list' do diff --git a/spec/models/concerns/milestoneable_spec.rb b/spec/models/concerns/milestoneable_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..186bf2c62907784679b5b7ac6591d75437266f7c --- /dev/null +++ b/spec/models/concerns/milestoneable_spec.rb @@ -0,0 +1,243 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Milestoneable do + let(:user) { create(:user) } + let(:milestone) { create(:milestone, project: project) } + + shared_examples_for 'an object that can be assigned a milestone' do + describe 'Validation' do + describe 'milestone' do + let(:project) { create(:project, :repository) } + let(:milestone_id) { milestone.id } + + subject { milestoneable_class.new(params) } + + context 'with correct params' do + it { is_expected.to be_valid } + end + + context 'with empty string milestone' do + let(:milestone_id) { '' } + + it { is_expected.to be_valid } + end + + context 'with nil milestone id' do + let(:milestone_id) { nil } + + it { is_expected.to be_valid } + end + + context 'with a milestone id from another project' do + let(:milestone_id) { create(:milestone).id } + + it { is_expected.to be_invalid } + end + + context 'when valid and saving' do + it 'copies the value to the new milestones relationship' do + subject.save! + + expect(subject.milestones).to match_array([milestone]) + end + + context 'with old values in milestones relationship' do + let(:old_milestone) { create(:milestone, project: project) } + + before do + subject.milestone = old_milestone + subject.save! + end + + it 'replaces old values' do + expect(subject.milestones).to match_array([old_milestone]) + + subject.milestone = milestone + subject.save! + + expect(subject.milestones).to match_array([milestone]) + end + + it 'can nullify the milestone' do + expect(subject.milestones).to match_array([old_milestone]) + + subject.milestone = nil + subject.save! + + expect(subject.milestones).to match_array([]) + end + end + end + end + end + + describe '#milestone_available?' do + let(:group) { create(:group) } + let(:project) { create(:project, group: group) } + let(:issue) { create(:issue, project: project) } + + def build_milestoneable(milestone_id) + milestoneable_class.new(project: project, milestone_id: milestone_id) + end + + it 'returns true with a milestone from the issue project' do + milestone = create(:milestone, project: project) + + expect(build_milestoneable(milestone.id).milestone_available?).to be_truthy + end + + it 'returns true with a milestone from the issue project group' do + milestone = create(:milestone, group: group) + + expect(build_milestoneable(milestone.id).milestone_available?).to be_truthy + end + + it 'returns true with a milestone from the the parent of the issue project group' do + parent = create(:group) + group.update(parent: parent) + milestone = create(:milestone, group: parent) + + expect(build_milestoneable(milestone.id).milestone_available?).to be_truthy + end + + it 'returns false with a milestone from another project' do + milestone = create(:milestone) + + expect(build_milestoneable(milestone.id).milestone_available?).to be_falsey + end + + it 'returns false with a milestone from another group' do + milestone = create(:milestone, group: create(:group)) + + expect(build_milestoneable(milestone.id).milestone_available?).to be_falsey + end + end + end + + describe '#supports_milestone?' do + let(:group) { create(:group) } + let(:project) { create(:project, group: group) } + + context "for issues" do + let(:issue) { build(:issue, project: project) } + + it 'returns true' do + expect(issue.supports_milestone?).to be_truthy + end + end + + context "for merge requests" do + let(:merge_request) { build(:merge_request, target_project: project, source_project: project) } + + it 'returns true' do + expect(merge_request.supports_milestone?).to be_truthy + end + end + end + + describe 'release scopes' do + let_it_be(:project) { create(:project) } + + let_it_be(:release_1) { create(:release, tag: 'v1.0', project: project) } + let_it_be(:release_2) { create(:release, tag: 'v2.0', project: project) } + let_it_be(:release_3) { create(:release, tag: 'v3.0', project: project) } + let_it_be(:release_4) { create(:release, tag: 'v4.0', project: project) } + + let_it_be(:milestone_1) { create(:milestone, releases: [release_1], title: 'm1', project: project) } + let_it_be(:milestone_2) { create(:milestone, releases: [release_1, release_2], title: 'm2', project: project) } + let_it_be(:milestone_3) { create(:milestone, releases: [release_2, release_4], title: 'm3', project: project) } + let_it_be(:milestone_4) { create(:milestone, releases: [release_3], title: 'm4', project: project) } + let_it_be(:milestone_5) { create(:milestone, releases: [release_3], title: 'm5', project: project) } + let_it_be(:milestone_6) { create(:milestone, title: 'm6', project: project) } + + let_it_be(:issue_1) { create(:issue, milestone: milestone_1, project: project) } + let_it_be(:issue_2) { create(:issue, milestone: milestone_1, project: project) } + let_it_be(:issue_3) { create(:issue, milestone: milestone_2, project: project) } + let_it_be(:issue_4) { create(:issue, milestone: milestone_5, project: project) } + let_it_be(:issue_5) { create(:issue, milestone: milestone_6, project: project) } + let_it_be(:issue_6) { create(:issue, project: project) } + + let_it_be(:items) { Issue.all } + + describe '#without_release' do + it 'returns the issues not tied to any milestone and the ones tied to milestone with no release' do + expect(items.without_release).to contain_exactly(issue_5, issue_6) + end + end + + describe '#any_release' do + it 'returns all issues tied to a release' do + expect(items.any_release).to contain_exactly(issue_1, issue_2, issue_3, issue_4) + end + end + + describe '#with_release' do + it 'returns the issues tied a specfic release' do + expect(items.with_release('v1.0', project.id)).to contain_exactly(issue_1, issue_2, issue_3) + end + + context 'when a release has a milestone with one issue and another one with no issue' do + it 'returns that one issue' do + expect(items.with_release('v2.0', project.id)).to contain_exactly(issue_3) + end + + context 'when the milestone with no issue is added as a filter' do + it 'returns an empty list' do + expect(items.with_release('v2.0', project.id).with_milestone('m3')).to be_empty + end + end + + context 'when the milestone with the issue is added as a filter' do + it 'returns this issue' do + expect(items.with_release('v2.0', project.id).with_milestone('m2')).to contain_exactly(issue_3) + end + end + end + + context 'when there is no issue under a specific release' do + it 'returns no issue' do + expect(items.with_release('v4.0', project.id)).to be_empty + end + end + + context 'when a non-existent release tag is passed in' do + it 'returns no issue' do + expect(items.with_release('v999.0', project.id)).to be_empty + end + end + end + end + + context 'Issues' do + let(:milestoneable_class) { Issue } + let(:params) do + { + title: 'something', + project: project, + author: user, + milestone_id: milestone_id + } + end + + it_behaves_like 'an object that can be assigned a milestone' + end + + context 'MergeRequests' do + let(:milestoneable_class) { MergeRequest } + let(:params) do + { + title: 'something', + source_project: project, + target_project: project, + source_branch: 'feature', + target_branch: 'master', + author: user, + milestone_id: milestone_id + } + end + + it_behaves_like 'an object that can be assigned a milestone' + end +end diff --git a/spec/models/concerns/prometheus_adapter_spec.rb b/spec/models/concerns/prometheus_adapter_spec.rb index 3d26ba95192ef32509d6bf4492672553c56211ad..3ac96b308ed087b32dd66bdbb19a9e1becab131a 100644 --- a/spec/models/concerns/prometheus_adapter_spec.rb +++ b/spec/models/concerns/prometheus_adapter_spec.rb @@ -103,6 +103,7 @@ describe PrometheusAdapter, :use_clean_rails_memory_store_caching do describe '#calculate_reactive_cache' do let(:environment) { create(:environment, slug: 'env-slug') } + before do service.manual_configuration = true service.active = true diff --git a/spec/models/concerns/resolvable_note_spec.rb b/spec/models/concerns/resolvable_note_spec.rb index 4f46252a04473255785d7327f580ae092ffc6a12..12e50ac807e68e22e598ad1658d0cf991c3c7737 100644 --- a/spec/models/concerns/resolvable_note_spec.rb +++ b/spec/models/concerns/resolvable_note_spec.rb @@ -5,6 +5,7 @@ require 'spec_helper' describe Note, ResolvableNote do let(:project) { create(:project, :repository) } let(:merge_request) { create(:merge_request, source_project: project) } + subject { create(:discussion_note_on_merge_request, noteable: merge_request, project: project) } context 'resolvability scopes' do diff --git a/spec/models/concerns/safe_url_spec.rb b/spec/models/concerns/safe_url_spec.rb index 0ad26660a6056851f8e3b6c876131561bf4345d1..e523e6a15e408bccf91937ca5deafed845759491 100644 --- a/spec/models/concerns/safe_url_spec.rb +++ b/spec/models/concerns/safe_url_spec.rb @@ -4,17 +4,19 @@ require 'spec_helper' describe SafeUrl do describe '#safe_url' do - class SafeUrlTestClass - include SafeUrl + let(:safe_url_test_class) do + Class.new do + include SafeUrl - attr_reader :url + attr_reader :url - def initialize(url) - @url = url + def initialize(url) + @url = url + end end end - let(:test_class) { SafeUrlTestClass.new(url) } + let(:test_class) { safe_url_test_class.new(url) } let(:url) { 'http://example.com' } subject { test_class.safe_url } diff --git a/spec/models/concerns/schedulable_spec.rb b/spec/models/concerns/schedulable_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..38ae2112e01b54bc5b71ec895618b7acdac6edcf --- /dev/null +++ b/spec/models/concerns/schedulable_spec.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Schedulable do + shared_examples 'before_save callback' do + it 'updates next_run_at' do + expect { object.save! }.to change { object.next_run_at } + end + end + + shared_examples '.runnable_schedules' do + it 'returns the runnable schedules' do + results = object.class.runnable_schedules + + expect(results).to include(object) + expect(results).not_to include(non_runnable_object) + end + end + + shared_examples '#schedule_next_run!' do + it 'saves the object and sets next_run_at' do + expect { object.schedule_next_run! }.to change { object.next_run_at } + end + + it 'sets next_run_at to nil on error' do + expect(object).to receive(:save!).and_raise(ActiveRecord::RecordInvalid) + + object.schedule_next_run! + + expect(object.next_run_at).to be_nil + end + end + + context 'for a pipeline_schedule' do + # let! is used to reset the next_run_at value before each spec + let(:object) do + Timecop.freeze(1.day.ago) do + create(:ci_pipeline_schedule, :hourly) + end + end + + let(:non_runnable_object) { create(:ci_pipeline_schedule, :hourly) } + + it_behaves_like '#schedule_next_run!' + it_behaves_like 'before_save callback' + it_behaves_like '.runnable_schedules' + end + + context 'for a container_expiration_policy' do + # let! is used to reset the next_run_at value before each spec + let(:object) { create(:container_expiration_policy, :runnable) } + let(:non_runnable_object) { create(:container_expiration_policy) } + + it_behaves_like '#schedule_next_run!' + it_behaves_like 'before_save callback' + it_behaves_like '.runnable_schedules' + end + + describe '#next_run_at' do + let(:schedulable_instance) do + Class.new(ActiveRecord::Base) do + include Schedulable + + # we need a table for the dummy class to operate + self.table_name = 'users' + end.new + end + + it 'works' do + expect { schedulable_instance.set_next_run_at }.to raise_error(NotImplementedError) + end + end +end diff --git a/spec/models/concerns/token_authenticatable_spec.rb b/spec/models/concerns/token_authenticatable_spec.rb index 43b894b59576e6392d274196bb7d367ca209a5d6..36eb8fdaba4702d1360021f30d53851e5a18ad36 100644 --- a/spec/models/concerns/token_authenticatable_spec.rb +++ b/spec/models/concerns/token_authenticatable_spec.rb @@ -13,6 +13,7 @@ end describe User, 'TokenAuthenticatable' do let(:token_field) { :feed_token } + it_behaves_like 'TokenAuthenticatable' describe 'ensures authentication token' do diff --git a/spec/models/container_expiration_policy_spec.rb b/spec/models/container_expiration_policy_spec.rb index 1ce76490448c68594e6ebd088e07f3faeaa03ac1..1bce4c3b20a0a6e3f43d304b87c3cd0cf0633ea1 100644 --- a/spec/models/container_expiration_policy_spec.rb +++ b/spec/models/container_expiration_policy_spec.rb @@ -38,4 +38,38 @@ RSpec.describe ContainerExpirationPolicy, type: :model do it { is_expected.not_to allow_value('foo').for(:keep_n) } end end + + describe '.preloaded' do + subject { described_class.preloaded } + + before do + create_list(:container_expiration_policy, 3) + end + + it 'preloads the associations' do + subject + + query = ActiveRecord::QueryRecorder.new { subject.each(&:project) } + + expect(query.count).to eq(2) + end + end + + describe '.runnable_schedules' do + subject { described_class.runnable_schedules } + + let!(:policy) { create(:container_expiration_policy, :runnable) } + + it 'returns the runnable schedule' do + is_expected.to eq([policy]) + end + + context 'when there are no runnable schedules' do + let!(:policy) { } + + it 'returns an empty array' do + is_expected.to be_empty + end + end + end end diff --git a/spec/models/cycle_analytics/code_spec.rb b/spec/models/cycle_analytics/code_spec.rb index 808659552ff8d7345f1b3ac28e4150461375f3ca..441f8265629dac46a5e2332e300882ed7feb82bb 100644 --- a/spec/models/cycle_analytics/code_spec.rb +++ b/spec/models/cycle_analytics/code_spec.rb @@ -5,11 +5,12 @@ require 'spec_helper' describe 'CycleAnalytics#code' do extend CycleAnalyticsHelpers::TestGeneration - let(:project) { create(:project, :repository) } - let(:from_date) { 10.days.ago } - let(:user) { create(:user, :admin) } + let_it_be(:project) { create(:project, :repository) } + let_it_be(:from_date) { 10.days.ago } + let_it_be(:user) { create(:user, :admin) } + let_it_be(:project_level) { CycleAnalytics::ProjectLevel.new(project, options: { from: from_date }) } - subject { CycleAnalytics::ProjectLevel.new(project, options: { from: from_date }) } + subject { project_level } context 'with deployment' do generate_cycle_analytics_spec( @@ -24,8 +25,6 @@ describe 'CycleAnalytics#code' do context.create_merge_request_closing_issue(context.user, context.project, data[:issue]) end]], post_fn: -> (context, data) do - context.merge_merge_requests_closing_issue(context.user, context.project, data[:issue]) - context.deploy_master(context.user, context.project) end) context "when a regular merge request (that doesn't close the issue) is created" do @@ -56,7 +55,6 @@ describe 'CycleAnalytics#code' do context.create_merge_request_closing_issue(context.user, context.project, data[:issue]) end]], post_fn: -> (context, data) do - context.merge_merge_requests_closing_issue(context.user, context.project, data[:issue]) end) context "when a regular merge request (that doesn't close the issue) is created" do diff --git a/spec/models/cycle_analytics/group_level_spec.rb b/spec/models/cycle_analytics/group_level_spec.rb index 0d2c14c29ddf5e40953db5d93f9ac74f92bcebd5..03fe8c3b50b2b1d0fdf5a5b84e80c0968bad8eef 100644 --- a/spec/models/cycle_analytics/group_level_spec.rb +++ b/spec/models/cycle_analytics/group_level_spec.rb @@ -3,12 +3,12 @@ require 'spec_helper' describe CycleAnalytics::GroupLevel do - let(:group) { create(:group)} - let(:project) { create(:project, :repository, namespace: group) } - let(:from_date) { 10.days.ago } - let(:user) { create(:user, :admin) } + let_it_be(:group) { create(:group)} + let_it_be(:project) { create(:project, :repository, namespace: group) } + let_it_be(:from_date) { 10.days.ago } + let_it_be(:user) { create(:user, :admin) } let(:issue) { create(:issue, project: project, created_at: 2.days.ago) } - let(:milestone) { create(:milestone, project: project) } + let_it_be(:milestone) { create(:milestone, project: project) } let(:mr) { create_merge_request_closing_issue(user, project, issue, commit_message: "References #{issue.to_reference}") } let(:pipeline) { create(:ci_empty_pipeline, status: 'created', project: project, ref: mr.source_branch, sha: mr.source_branch_sha, head_pipeline_of: mr) } diff --git a/spec/models/cycle_analytics/issue_spec.rb b/spec/models/cycle_analytics/issue_spec.rb index 8cdf83b1292bc1282bed319457d3e04c5b569a01..726f2f8b018c83b9af7d6978423a40c84bd63bd2 100644 --- a/spec/models/cycle_analytics/issue_spec.rb +++ b/spec/models/cycle_analytics/issue_spec.rb @@ -5,11 +5,12 @@ require 'spec_helper' describe 'CycleAnalytics#issue' do extend CycleAnalyticsHelpers::TestGeneration - let(:project) { create(:project, :repository) } - let(:from_date) { 10.days.ago } - let(:user) { create(:user, :admin) } + let_it_be(:project) { create(:project, :repository) } + let_it_be(:from_date) { 10.days.ago } + let_it_be(:user) { create(:user, :admin) } + let_it_be(:project_level) { CycleAnalytics::ProjectLevel.new(project, options: { from: from_date }) } - subject { CycleAnalytics::ProjectLevel.new(project, options: { from: from_date }) } + subject { project_level } generate_cycle_analytics_spec( phase: :issue, @@ -28,10 +29,6 @@ describe 'CycleAnalytics#issue' do end end]], post_fn: -> (context, data) do - if data[:issue].persisted? - context.create_merge_request_closing_issue(context.user, context.project, data[:issue].reload) - context.merge_merge_requests_closing_issue(context.user, context.project, data[:issue]) - end end) context "when a regular label (instead of a list label) is added to the issue" do diff --git a/spec/models/cycle_analytics/plan_spec.rb b/spec/models/cycle_analytics/plan_spec.rb index 28ad9bd194d3e5a4b62290d89d5a54759da3ea12..3bd9f317ca70718e470c51dc9bf254ada98ac39f 100644 --- a/spec/models/cycle_analytics/plan_spec.rb +++ b/spec/models/cycle_analytics/plan_spec.rb @@ -5,17 +5,18 @@ require 'spec_helper' describe 'CycleAnalytics#plan' do extend CycleAnalyticsHelpers::TestGeneration - let(:project) { create(:project, :repository) } - let(:from_date) { 10.days.ago } - let(:user) { create(:user, :admin) } + let_it_be(:project) { create(:project, :repository) } + let_it_be(:from_date) { 10.days.ago } + let_it_be(:user) { create(:user, :admin) } + let_it_be(:project_level) { CycleAnalytics::ProjectLevel.new(project, options: { from: from_date }) } - subject { CycleAnalytics::ProjectLevel.new(project, options: { from: from_date }) } + subject { project_level } generate_cycle_analytics_spec( phase: :plan, data_fn: -> (context) do { - issue: context.create(:issue, project: context.project), + issue: context.build(:issue, project: context.project), branch_name: context.generate(:branch) } end, @@ -32,8 +33,6 @@ describe 'CycleAnalytics#plan' do context.create_commit_referencing_issue(data[:issue], branch_name: data[:branch_name]) end]], post_fn: -> (context, data) do - context.create_merge_request_closing_issue(context.user, context.project, data[:issue], source_branch: data[:branch_name]) - context.merge_merge_requests_closing_issue(context.user, context.project, data[:issue]) end) context "when a regular label (instead of a list label) is added to the issue" do diff --git a/spec/models/cycle_analytics/production_spec.rb b/spec/models/cycle_analytics/production_spec.rb index 613c17865400529cdc658108df41252b4a596ec7..01d88bbeec985f561e0de662071a560e6a183cf1 100644 --- a/spec/models/cycle_analytics/production_spec.rb +++ b/spec/models/cycle_analytics/production_spec.rb @@ -5,11 +5,12 @@ require 'spec_helper' describe 'CycleAnalytics#production' do extend CycleAnalyticsHelpers::TestGeneration - let(:project) { create(:project, :repository) } - let(:from_date) { 10.days.ago } - let(:user) { create(:user, :admin) } + let_it_be(:project) { create(:project, :repository) } + let_it_be(:from_date) { 10.days.ago } + let_it_be(:user) { create(:user, :admin) } + let_it_be(:project_level) { CycleAnalytics::ProjectLevel.new(project, options: { from: from_date }) } - subject { CycleAnalytics::ProjectLevel.new(project, options: { from: from_date }) } + subject { project_level } generate_cycle_analytics_spec( phase: :production, @@ -24,13 +25,7 @@ describe 'CycleAnalytics#production' do ["production deploy happens after merge request is merged (along with other changes)", lambda do |context, data| # Make other changes on master - sha = context.project.repository.create_file( - context.user, - context.generate(:branch), - 'content', - message: 'commit message', - branch_name: 'master') - context.project.repository.commit(sha) + context.project.repository.commit("sha_that_does_not_matter") context.deploy_master(context.user, context.project) end]]) @@ -47,7 +42,7 @@ describe 'CycleAnalytics#production' do context "when the deployment happens to a non-production environment" do it "returns nil" do - issue = create(:issue, project: project) + issue = build(:issue, project: project) merge_request = create_merge_request_closing_issue(user, project, issue) MergeRequests::MergeService.new(project, user).execute(merge_request) deploy_master(user, project, environment: 'staging') diff --git a/spec/models/cycle_analytics/project_level_spec.rb b/spec/models/cycle_analytics/project_level_spec.rb index 351eb139416a14533b3a5d105dae8c7588023620..2fc817777467477bc858288426ed4e9675c0bf95 100644 --- a/spec/models/cycle_analytics/project_level_spec.rb +++ b/spec/models/cycle_analytics/project_level_spec.rb @@ -3,11 +3,11 @@ require 'spec_helper' describe CycleAnalytics::ProjectLevel do - let(:project) { create(:project, :repository) } - let(:from_date) { 10.days.ago } - let(:user) { create(:user, :admin) } - let(:issue) { create(:issue, project: project, created_at: 2.days.ago) } - let(:milestone) { create(:milestone, project: project) } + let_it_be(:project) { create(:project, :repository) } + let_it_be(:from_date) { 10.days.ago } + let_it_be(:user) { create(:user, :admin) } + let_it_be(:issue) { create(:issue, project: project, created_at: 2.days.ago) } + let_it_be(:milestone) { create(:milestone, project: project) } let(:mr) { create_merge_request_closing_issue(user, project, issue, commit_message: "References #{issue.to_reference}") } let(:pipeline) { create(:ci_empty_pipeline, status: 'created', project: project, ref: mr.source_branch, sha: mr.source_branch_sha, head_pipeline_of: mr) } diff --git a/spec/models/cycle_analytics/review_spec.rb b/spec/models/cycle_analytics/review_spec.rb index ef88fd8634067589e519c88935ee8290230143b4..50670188e85fa347e9e3950a0e65946eee524b82 100644 --- a/spec/models/cycle_analytics/review_spec.rb +++ b/spec/models/cycle_analytics/review_spec.rb @@ -5,9 +5,9 @@ require 'spec_helper' describe 'CycleAnalytics#review' do extend CycleAnalyticsHelpers::TestGeneration - let(:project) { create(:project, :repository) } - let(:from_date) { 10.days.ago } - let(:user) { create(:user, :admin) } + let_it_be(:project) { create(:project, :repository) } + let_it_be(:from_date) { 10.days.ago } + let_it_be(:user) { create(:user, :admin) } subject { CycleAnalytics::ProjectLevel.new(project, options: { from: from_date }) } diff --git a/spec/models/cycle_analytics/staging_spec.rb b/spec/models/cycle_analytics/staging_spec.rb index 571792559d86cf7202c40eda7ec819a70018b2c3..cf0695f175a3ddf29419e4c56e142b7cb2de2e30 100644 --- a/spec/models/cycle_analytics/staging_spec.rb +++ b/spec/models/cycle_analytics/staging_spec.rb @@ -5,11 +5,12 @@ require 'spec_helper' describe 'CycleAnalytics#staging' do extend CycleAnalyticsHelpers::TestGeneration - let(:project) { create(:project, :repository) } - let(:from_date) { 10.days.ago } - let(:user) { create(:user, :admin) } + let_it_be(:project) { create(:project, :repository) } + let_it_be(:from_date) { 10.days.ago } + let_it_be(:user) { create(:user, :admin) } + let_it_be(:project_level) { CycleAnalytics::ProjectLevel.new(project, options: { from: from_date }) } - subject { CycleAnalytics::ProjectLevel.new(project, options: { from: from_date }) } + subject { project_level } generate_cycle_analytics_spec( phase: :staging, @@ -28,14 +29,7 @@ describe 'CycleAnalytics#staging' do ["production deploy happens after merge request is merged (along with other changes)", lambda do |context, data| # Make other changes on master - sha = context.project.repository.create_file( - context.user, - context.generate(:branch), - 'content', - message: 'commit message', - branch_name: 'master') - context.project.repository.commit(sha) - + context.project.repository.commit("this_sha_apparently_does_not_matter") context.deploy_master(context.user, context.project) end]]) diff --git a/spec/models/cycle_analytics/test_spec.rb b/spec/models/cycle_analytics/test_spec.rb index 7b3001d2bd828f8603004cb8b7eafac8f19a68c3..24800aafca7a44a997b20e95a5e24b3d5301d3ac 100644 --- a/spec/models/cycle_analytics/test_spec.rb +++ b/spec/models/cycle_analytics/test_spec.rb @@ -5,16 +5,19 @@ require 'spec_helper' describe 'CycleAnalytics#test' do extend CycleAnalyticsHelpers::TestGeneration - let(:project) { create(:project, :repository) } - let(:from_date) { 10.days.ago } - let(:user) { create(:user, :admin) } + let_it_be(:project) { create(:project, :repository) } + let_it_be(:from_date) { 10.days.ago } + let_it_be(:user) { create(:user, :admin) } + let_it_be(:issue) { create(:issue, project: project) } + let_it_be(:project_level) { CycleAnalytics::ProjectLevel.new(project, options: { from: from_date }) } + let!(:merge_request) { create_merge_request_closing_issue(user, project, issue) } - subject { CycleAnalytics::ProjectLevel.new(project, options: { from: from_date }) } + subject { project_level } generate_cycle_analytics_spec( phase: :test, data_fn: lambda do |context| - issue = context.create(:issue, project: context.project) + issue = context.issue merge_request = context.create_merge_request_closing_issue(context.user, context.project, issue) pipeline = context.create(:ci_pipeline, ref: merge_request.source_branch, sha: merge_request.diff_head_sha, project: context.project, head_pipeline_of: merge_request) { pipeline: pipeline, issue: issue } @@ -22,20 +25,15 @@ describe 'CycleAnalytics#test' do start_time_conditions: [["pipeline is started", -> (context, data) { data[:pipeline].run! }]], end_time_conditions: [["pipeline is finished", -> (context, data) { data[:pipeline].succeed! }]], post_fn: -> (context, data) do - context.merge_merge_requests_closing_issue(context.user, context.project, data[:issue]) end) context "when the pipeline is for a regular merge request (that doesn't close an issue)" do it "returns nil" do - issue = create(:issue, project: project) - merge_request = create_merge_request_closing_issue(user, project, issue) pipeline = create(:ci_pipeline, ref: "refs/heads/#{merge_request.source_branch}", sha: merge_request.diff_head_sha) pipeline.run! pipeline.succeed! - merge_merge_requests_closing_issue(user, project, issue) - expect(subject[:test].project_median).to be_nil end end @@ -53,30 +51,22 @@ describe 'CycleAnalytics#test' do context "when the pipeline is dropped (failed)" do it "returns nil" do - issue = create(:issue, project: project) - merge_request = create_merge_request_closing_issue(user, project, issue) pipeline = create(:ci_pipeline, ref: "refs/heads/#{merge_request.source_branch}", sha: merge_request.diff_head_sha) pipeline.run! pipeline.drop! - merge_merge_requests_closing_issue(user, project, issue) - expect(subject[:test].project_median).to be_nil end end context "when the pipeline is cancelled" do it "returns nil" do - issue = create(:issue, project: project) - merge_request = create_merge_request_closing_issue(user, project, issue) pipeline = create(:ci_pipeline, ref: "refs/heads/#{merge_request.source_branch}", sha: merge_request.diff_head_sha) pipeline.run! pipeline.cancel! - merge_merge_requests_closing_issue(user, project, issue) - expect(subject[:test].project_median).to be_nil end end diff --git a/spec/models/deployment_metrics_spec.rb b/spec/models/deployment_metrics_spec.rb index 7c574a8b6c88fbd29454c05015dd87771d234aff..32c04e15b73049fd9e794968e9f818a8f8e4f24d 100644 --- a/spec/models/deployment_metrics_spec.rb +++ b/spec/models/deployment_metrics_spec.rb @@ -20,7 +20,7 @@ describe DeploymentMetrics do end context 'with a Prometheus Service' do - let(:prometheus_service) { instance_double(PrometheusService, can_query?: true) } + let(:prometheus_service) { instance_double(PrometheusService, can_query?: true, configured?: true) } before do allow(deployment.project).to receive(:find_or_initialize_service).with('prometheus').and_return prometheus_service @@ -30,7 +30,17 @@ describe DeploymentMetrics do end context 'with a Prometheus Service that cannot query' do - let(:prometheus_service) { instance_double(PrometheusService, can_query?: false) } + let(:prometheus_service) { instance_double(PrometheusService, configured?: true, can_query?: false) } + + before do + allow(deployment.project).to receive(:find_or_initialize_service).with('prometheus').and_return prometheus_service + end + + it { is_expected.to be_falsy } + end + + context 'with a Prometheus Service that is not configured' do + let(:prometheus_service) { instance_double(PrometheusService, configured?: false, can_query?: false) } before do allow(deployment.project).to receive(:find_or_initialize_service).with('prometheus').and_return prometheus_service @@ -44,7 +54,7 @@ describe DeploymentMetrics do let!(:prometheus) { create(:clusters_applications_prometheus, :installed, cluster: deployment.cluster) } before do - expect(deployment.cluster.application_prometheus).to receive(:can_query?).and_return(true) + expect(deployment.cluster.application_prometheus).to receive(:configured?).and_return(true) end it { is_expected.to be_truthy } @@ -54,7 +64,7 @@ describe DeploymentMetrics do describe '#metrics' do let(:deployment) { create(:deployment, :success) } - let(:prometheus_adapter) { instance_double(PrometheusService, can_query?: true) } + let(:prometheus_adapter) { instance_double(PrometheusService, can_query?: true, configured?: true) } let(:deployment_metrics) { described_class.new(deployment.project, deployment) } subject { deployment_metrics.metrics } @@ -101,7 +111,7 @@ describe DeploymentMetrics do } end - let(:prometheus_adapter) { instance_double('prometheus_adapter', can_query?: true) } + let(:prometheus_adapter) { instance_double('prometheus_adapter', can_query?: true, configured?: true) } before do allow(deployment_metrics).to receive(:prometheus_adapter).and_return(prometheus_adapter) diff --git a/spec/models/deployment_spec.rb b/spec/models/deployment_spec.rb index 33e4cd34aa5b8a7818686b6d4a74d5db73ad3171..0c1b259d6bf440d657bd89ad47e55225b46f5120 100644 --- a/spec/models/deployment_spec.rb +++ b/spec/models/deployment_spec.rb @@ -399,44 +399,64 @@ describe Deployment do expect(deploy.merge_requests).to include(mr1, mr2) end + + it 'ignores already linked merge requests' do + deploy = create(:deployment) + mr1 = create( + :merge_request, + :merged, + target_project: deploy.project, + source_project: deploy.project + ) + + deploy.link_merge_requests(deploy.project.merge_requests) + + mr2 = create( + :merge_request, + :merged, + target_project: deploy.project, + source_project: deploy.project + ) + + deploy.link_merge_requests(deploy.project.merge_requests) + + expect(deploy.merge_requests).to include(mr1, mr2) + end end describe '#previous_environment_deployment' do it 'returns the previous deployment of the same environment' do - deploy1 = create(:deployment, :success, ref: 'v1.0.0') + deploy1 = create(:deployment, :success) deploy2 = create( :deployment, :success, project: deploy1.project, - environment: deploy1.environment, - ref: 'v1.0.1' + environment: deploy1.environment ) expect(deploy2.previous_environment_deployment).to eq(deploy1) end it 'ignores deployments that were not successful' do - deploy1 = create(:deployment, :failed, ref: 'v1.0.0') + deploy1 = create(:deployment, :failed) deploy2 = create( :deployment, :success, project: deploy1.project, - environment: deploy1.environment, - ref: 'v1.0.1' + environment: deploy1.environment ) expect(deploy2.previous_environment_deployment).to be_nil end it 'ignores deployments for different environments' do - deploy1 = create(:deployment, :success, ref: 'v1.0.0') + deploy1 = create(:deployment, :success) preprod = create(:environment, project: deploy1.project, name: 'preprod') deploy2 = create( :deployment, :success, project: deploy1.project, - environment: preprod, - ref: 'v1.0.1' + environment: preprod ) expect(deploy2.previous_environment_deployment).to be_nil @@ -499,4 +519,36 @@ describe Deployment do end end end + + describe '#valid_sha' do + it 'does not add errors for a valid SHA' do + project = create(:project, :repository) + deploy = build(:deployment, project: project) + + expect(deploy).to be_valid + end + + it 'adds an error for an invalid SHA' do + deploy = build(:deployment, sha: 'foo') + + expect(deploy).not_to be_valid + expect(deploy.errors[:sha]).not_to be_empty + end + end + + describe '#valid_ref' do + it 'does not add errors for a valid ref' do + project = create(:project, :repository) + deploy = build(:deployment, project: project) + + expect(deploy).to be_valid + end + + it 'adds an error for an invalid ref' do + deploy = build(:deployment, ref: 'does-not-exist') + + expect(deploy).not_to be_valid + expect(deploy.errors[:ref]).not_to be_empty + end + end end diff --git a/spec/models/diff_note_spec.rb b/spec/models/diff_note_spec.rb index 601dac21e6af4f3e5219eee264014595111cc549..b802c8ba506b86164dfef4f0b5885390e3b7ba9a 100644 --- a/spec/models/diff_note_spec.rb +++ b/spec/models/diff_note_spec.rb @@ -5,11 +5,11 @@ require 'spec_helper' describe DiffNote do include RepoHelpers - let!(:merge_request) { create(:merge_request) } - let(:project) { merge_request.project } - let(:commit) { project.commit(sample_commit.id) } + let_it_be(:merge_request) { create(:merge_request) } + let_it_be(:project) { merge_request.project } + let_it_be(:commit) { project.commit(sample_commit.id) } - let(:path) { "files/ruby/popen.rb" } + let_it_be(:path) { "files/ruby/popen.rb" } let(:diff_refs) { merge_request.diff_refs } let!(:position) do @@ -91,18 +91,124 @@ describe DiffNote do end describe '#create_diff_file callback' do - let(:noteable) { create(:merge_request) } - let(:project) { noteable.project } - context 'merge request' do - let!(:diff_note) { create(:diff_note_on_merge_request, project: project, noteable: noteable) } + let(:position) do + Gitlab::Diff::Position.new(old_path: "files/ruby/popen.rb", + new_path: "files/ruby/popen.rb", + old_line: nil, + new_line: 9, + diff_refs: merge_request.diff_refs) + end - it 'creates a diff note file' do - expect(diff_note.reload.note_diff_file).to be_present + subject { build(:diff_note_on_merge_request, project: project, position: position, noteable: merge_request) } + + let(:diff_file_from_repository) do + position.diff_file(project.repository) + end + + let(:diff_file) do + diffs = merge_request.diffs + raw_diff = diffs.diffable.raw_diffs(diffs.diff_options.merge(paths: ['files/ruby/popen.rb'])).first + Gitlab::Diff::File.new(raw_diff, + repository: diffs.project.repository, + diff_refs: diffs.diff_refs, + fallback_diff_refs: diffs.fallback_diff_refs) + end + + let(:diff_line) { diff_file.diff_lines.first } + + let(:line_code) { '2f6fcd96b88b36ce98c38da085c795a27d92a3dd_15_14' } + + before do + allow(subject.position).to receive(:line_code).and_return('2f6fcd96b88b36ce98c38da085c795a27d92a3dd_15_14') + end + + context 'when diffs are already created' do + before do + allow(subject).to receive(:created_at_diff?).and_return(true) + end + + context 'when diff_file is found in persisted diffs' do + before do + allow(merge_request).to receive_message_chain(:diffs, :diff_files, :first).and_return(diff_file) + end + + context 'when importing' do + before do + subject.importing = true + subject.line_code = line_code + end + + context 'when diff_line is found in persisted diff_file' do + before do + allow(diff_file).to receive(:line_for_position).with(position).and_return(diff_line) + end + + it 'creates a diff note file' do + subject.save + expect(subject.note_diff_file).to be_present + end + end + + context 'when diff_line is not found in persisted diff_file' do + before do + allow(diff_file).to receive(:line_for_position).and_return(nil) + end + + it_behaves_like 'a valid diff note with after commit callback' + end + end + + context 'when not importing' do + context 'when diff_line is not found' do + before do + allow(diff_file).to receive(:line_for_position).with(position).and_return(nil) + end + + it 'raises an error' do + expect { subject.save }.to raise_error(::DiffNote::NoteDiffFileCreationError, + "Failed to find diff line for: #{diff_file.file_path}, "\ + "old_line: #{position.old_line}"\ + ", new_line: #{position.new_line}") + end + end + + context 'when diff_line is found' do + before do + allow(diff_file).to receive(:line_for_position).with(position).and_return(diff_line) + end + + it 'creates a diff note file' do + subject.save + expect(subject.reload.note_diff_file).to be_present + end + end + end + end + + context 'when diff file is not found in persisted diffs' do + before do + allow_next_instance_of(Gitlab::Diff::FileCollection::MergeRequestDiff) do |merge_request_diff| + allow(merge_request_diff).to receive(:diff_files).and_return([]) + end + end + + it_behaves_like 'a valid diff note with after commit callback' + end + end + + context 'when diffs are not already created' do + before do + allow(subject).to receive(:created_at_diff?).and_return(false) + end + + it_behaves_like 'a valid diff note with after commit callback' end it 'does not create diff note file if it is a reply' do - expect { create(:diff_note_on_merge_request, noteable: noteable, in_reply_to: diff_note) } + diff_note = create(:diff_note_on_merge_request, project: project, noteable: merge_request) + + expect { create(:diff_note_on_merge_request, noteable: merge_request, in_reply_to: diff_note) } .not_to change(NoteDiffFile, :count) end end diff --git a/spec/models/diff_viewer/base_spec.rb b/spec/models/diff_viewer/base_spec.rb index 019597993cccace0dc9417650d9739bf3f89f3ae..0a1c4c5560e4818ec306c6ab0c41a262728fb42d 100644 --- a/spec/models/diff_viewer/base_spec.rb +++ b/spec/models/diff_viewer/base_spec.rb @@ -43,34 +43,6 @@ describe DiffViewer::Base do end end - context 'when the file type is supported' do - let(:commit) { project.commit('1a0b36b3cdad1d2ee32457c102a8c0b7056fa863') } - let(:diff_file) { commit.diffs.diff_file_with_new_path('LICENSE') } - - before do - viewer_class.file_types = %i(license) - viewer_class.binary = false - end - - context 'when the binaryness matches' do - it 'returns true' do - expect(viewer_class.can_render?(diff_file)).to be_truthy - end - end - - context 'when the binaryness does not match' do - before do - allow_next_instance_of(Blob) do |instance| - allow(instance).to receive(:binary_in_repo?).and_return(true) - end - end - - it 'returns false' do - expect(viewer_class.can_render?(diff_file)).to be_falsey - end - end - end - context 'when the extension and file type are not supported' do it 'returns false' do expect(viewer_class.can_render?(diff_file)).to be_falsey diff --git a/spec/models/environment_spec.rb b/spec/models/environment_spec.rb index 0537220fcd269bbf6e7c9033f68e4e48e409ac64..af7ab24d7d68ec4f71c740b0a3797875c4b5455d 100644 --- a/spec/models/environment_spec.rb +++ b/spec/models/environment_spec.rb @@ -6,8 +6,10 @@ describe Environment, :use_clean_rails_memory_store_caching do include ReactiveCachingHelpers using RSpec::Parameterized::TableSyntax include RepoHelpers + include StubENV + + let(:project) { create(:project, :repository) } - let(:project) { create(:project, :stubbed_repository) } subject(:environment) { create(:environment, project: project) } it { is_expected.to be_kind_of(ReactiveCaching) } @@ -28,7 +30,6 @@ describe Environment, :use_clean_rails_memory_store_caching do it { is_expected.to validate_length_of(:external_url).is_at_most(255) } describe '.order_by_last_deployed_at' do - let(:project) { create(:project, :repository) } let!(:environment1) { create(:environment, project: project) } let!(:environment2) { create(:environment, project: project) } let!(:environment3) { create(:environment, project: project) } @@ -36,9 +37,13 @@ describe Environment, :use_clean_rails_memory_store_caching do let!(:deployment2) { create(:deployment, environment: environment2) } let!(:deployment3) { create(:deployment, environment: environment1) } - it 'returns the environments in order of having been last deployed' do + it 'returns the environments in ascending order of having been last deployed' do expect(project.environments.order_by_last_deployed_at.to_a).to eq([environment3, environment2, environment1]) end + + it 'returns the environments in descending order of having been last deployed' do + expect(project.environments.order_by_last_deployed_at_desc.to_a).to eq([environment1, environment2, environment3]) + end end describe 'state machine' do @@ -134,8 +139,8 @@ describe Environment, :use_clean_rails_memory_store_caching do describe '.with_deployment' do subject { described_class.with_deployment(sha) } - let(:environment) { create(:environment) } - let(:sha) { RepoHelpers.sample_commit.id } + let(:environment) { create(:environment, project: project) } + let(:sha) { 'b83d6e391c22777fca1ed3012fce84f633d7fed0' } context 'when deployment has the specified sha' do let!(:deployment) { create(:deployment, environment: environment, sha: sha) } @@ -144,7 +149,7 @@ describe Environment, :use_clean_rails_memory_store_caching do end context 'when deployment does not have the specified sha' do - let!(:deployment) { create(:deployment, environment: environment, sha: 'abc') } + let!(:deployment) { create(:deployment, environment: environment, sha: 'ddd0f15ae83993f5cb66a927a28673882e99100b') } it { is_expected.to be_empty } end @@ -153,7 +158,7 @@ describe Environment, :use_clean_rails_memory_store_caching do describe '#folder_name' do context 'when it is inside a folder' do subject(:environment) do - create(:environment, name: 'staging/review-1') + create(:environment, name: 'staging/review-1', project: project) end it 'returns a top-level folder name' do @@ -667,11 +672,11 @@ describe Environment, :use_clean_rails_memory_store_caching do context 'when the environment is available' do context 'with a deployment service' do context 'when user configured kubernetes from CI/CD > Clusters' do - let!(:cluster) { create(:cluster, :project, :provided_by_gcp) } - let(:project) { cluster.project } + let!(:cluster) { create(:cluster, :project, :provided_by_gcp, projects: [project]) } context 'with deployment' do let!(:deployment) { create(:deployment, :success, environment: environment) } + it { is_expected.to be_truthy } end @@ -788,10 +793,9 @@ describe Environment, :use_clean_rails_memory_store_caching do end describe '#calculate_reactive_cache' do - let(:cluster) { create(:cluster, :project, :provided_by_user) } - let(:project) { cluster.project } - let(:environment) { create(:environment, project: project) } - let!(:deployment) { create(:deployment, :success, environment: environment) } + let!(:cluster) { create(:cluster, :project, :provided_by_user, projects: [project]) } + let!(:environment) { create(:environment, project: project) } + let!(:deployment) { create(:deployment, :success, environment: environment, project: project) } subject { environment.calculate_reactive_cache } @@ -824,10 +828,11 @@ describe Environment, :use_clean_rails_memory_store_caching do context 'when the environment is available' do context 'with a deployment service' do - let(:project) { create(:prometheus_project) } + let(:project) { create(:prometheus_project, :repository) } context 'and a deployment' do let!(:deployment) { create(:deployment, environment: environment) } + it { is_expected.to be_truthy } end @@ -847,6 +852,52 @@ describe Environment, :use_clean_rails_memory_store_caching do context 'without a monitoring service' do it { is_expected.to be_falsy } end + + context 'when sample metrics are enabled' do + before do + stub_env('USE_SAMPLE_METRICS', 'true') + end + + context 'with no prometheus adapter configured' do + before do + allow(environment.prometheus_adapter).to receive(:configured?).and_return(false) + end + + it { is_expected.to be_truthy } + end + end + end + + describe '#has_sample_metrics?' do + subject { environment.has_metrics? } + + let(:project) { create(:project) } + + context 'when sample metrics are enabled' do + before do + stub_env('USE_SAMPLE_METRICS', 'true') + end + + context 'with no prometheus adapter configured' do + before do + allow(environment.prometheus_adapter).to receive(:configured?).and_return(false) + end + + it { is_expected.to be_truthy } + end + + context 'with the environment stopped' do + before do + environment.stop + end + + it { is_expected.to be_falsy } + end + end + + context 'when sample metrics are not enabled' do + it { is_expected.to be_falsy } + end end context 'when the environment is unavailable' do @@ -862,6 +913,7 @@ describe Environment, :use_clean_rails_memory_store_caching do describe '#metrics' do let(:project) { create(:prometheus_project) } + subject { environment.metrics } context 'when the environment has metrics' do @@ -943,6 +995,7 @@ describe Environment, :use_clean_rails_memory_store_caching do describe '#additional_metrics' do let(:project) { create(:prometheus_project) } let(:metric_params) { [] } + subject { environment.additional_metrics(*metric_params) } context 'when the environment has additional metrics' do @@ -1059,7 +1112,7 @@ describe Environment, :use_clean_rails_memory_store_caching do describe '#prometheus_adapter' do it 'calls prometheus adapter service' do - expect_next_instance_of(Prometheus::AdapterService) do |instance| + expect_next_instance_of(Gitlab::Prometheus::Adapter) do |instance| expect(instance).to receive(:prometheus_adapter) end diff --git a/spec/models/error_tracking/project_error_tracking_setting_spec.rb b/spec/models/error_tracking/project_error_tracking_setting_spec.rb index ef426661066171ffc6f2b9f58c8dce076d47ac37..5b402e572c3c00d32def040758e1da5bf9e06e5c 100644 --- a/spec/models/error_tracking/project_error_tracking_setting_spec.rb +++ b/spec/models/error_tracking/project_error_tracking_setting_spec.rb @@ -4,6 +4,7 @@ require 'spec_helper' describe ErrorTracking::ProjectErrorTrackingSetting do include ReactiveCachingHelpers + include Gitlab::Routing let_it_be(:project) { create(:project) } @@ -63,6 +64,22 @@ describe ErrorTracking::ProjectErrorTrackingSetting do end end + describe '.extract_sentry_external_url' do + subject { described_class.extract_sentry_external_url(sentry_url) } + + describe 'when passing a URL' do + let(:sentry_url) { 'https://sentrytest.gitlab.com/api/0/projects/sentry-org/sentry-project' } + + it { is_expected.to eq('https://sentrytest.gitlab.com/sentry-org/sentry-project') } + end + + describe 'when passing nil' do + let(:sentry_url) { nil } + + it { is_expected.to be_nil } + end + end + describe '#sentry_external_url' do let(:sentry_url) { 'https://sentrytest.gitlab.com/api/0/projects/sentry-org/sentry-project' } @@ -138,8 +155,6 @@ describe ErrorTracking::ProjectErrorTrackingSetting do error: 'error message', error_type: ErrorTracking::ProjectErrorTrackingSetting::SENTRY_API_ERROR_TYPE_NON_20X_RESPONSE ) - expect(subject).to have_received(:sentry_client) - expect(sentry_client).to have_received(:list_issues) end end @@ -159,8 +174,6 @@ describe ErrorTracking::ProjectErrorTrackingSetting do error: 'Sentry API response is missing keys. key not found: "id"', error_type: ErrorTracking::ProjectErrorTrackingSetting::SENTRY_API_ERROR_TYPE_MISSING_KEYS ) - expect(subject).to have_received(:sentry_client) - expect(sentry_client).to have_received(:list_issues) end end @@ -181,8 +194,21 @@ describe ErrorTracking::ProjectErrorTrackingSetting do error: error_msg, error_type: ErrorTracking::ProjectErrorTrackingSetting::SENTRY_API_ERROR_INVALID_SIZE ) - expect(subject).to have_received(:sentry_client) - expect(sentry_client).to have_received(:list_issues) + end + end + + context 'when sentry client raises StandardError' do + let(:sentry_client) { spy(:sentry_client) } + + before do + synchronous_reactive_cache(subject) + + allow(subject).to receive(:sentry_client).and_return(sentry_client) + allow(sentry_client).to receive(:list_issues).with(opts).and_raise(StandardError) + end + + it 'returns error' do + expect(result).to eq(error: 'Unexpected Error') end end end @@ -201,6 +227,90 @@ describe ErrorTracking::ProjectErrorTrackingSetting do end end + describe '#issue_details' do + let(:issue) { build(:detailed_error_tracking_error) } + let(:sentry_client) { double('sentry_client', issue_details: issue) } + let(:commit_id) { issue.first_release_version } + + let(:result) do + subject.issue_details + end + + context 'when cached' do + before do + stub_reactive_cache(subject, issue, {}) + synchronous_reactive_cache(subject) + + expect(subject).to receive(:sentry_client).and_return(sentry_client) + end + + it { expect(result).to eq(issue: issue) } + it { expect(result[:issue].first_release_version).to eq(commit_id) } + it { expect(result[:issue].gitlab_commit).to eq(nil) } + it { expect(result[:issue].gitlab_commit_path).to eq(nil) } + + context 'when release version is nil' do + before do + issue.first_release_version = nil + end + + it { expect(result[:issue].gitlab_commit).to eq(nil) } + it { expect(result[:issue].gitlab_commit_path).to eq(nil) } + end + + context 'when repo commit matches first relase version' do + let(:commit) { double('commit', id: commit_id) } + let(:repository) { double('repository', commit: commit) } + + before do + expect(project).to receive(:repository).and_return(repository) + end + + it { expect(result[:issue].gitlab_commit).to eq(commit_id) } + it { expect(result[:issue].gitlab_commit_path).to eq("/#{project.namespace.path}/#{project.path}/commit/#{commit_id}") } + end + end + + context 'when not cached' do + it { expect(subject).not_to receive(:sentry_client) } + it { expect(result).to be_nil } + end + end + + describe '#update_issue' do + let(:opts) do + { status: 'resolved' } + end + + let(:result) do + subject.update_issue(**opts) + end + + let(:sentry_client) { spy(:sentry_client) } + + context 'successful call to sentry' do + before do + allow(subject).to receive(:sentry_client).and_return(sentry_client) + allow(sentry_client).to receive(:update_issue).with(opts).and_return(true) + end + + it 'returns the successful response' do + expect(result).to eq(updated: true) + end + end + + context 'sentry raises an error' do + before do + allow(subject).to receive(:sentry_client).and_return(sentry_client) + allow(sentry_client).to receive(:update_issue).with(opts).and_raise(StandardError) + end + + it 'returns the successful response' do + expect(result).to eq(error: 'Unexpected Error') + end + end + end + context 'slugs' do shared_examples_for 'slug from api_url' do |method, slug| context 'when api_url is correct' do diff --git a/spec/models/external_issue_spec.rb b/spec/models/external_issue_spec.rb index 9d064d458f08db5a5a3321ddacdb4ed57868aec7..b8d85d49b07373f70022ddbe0b91aee27c83bdb4 100644 --- a/spec/models/external_issue_spec.rb +++ b/spec/models/external_issue_spec.rb @@ -33,6 +33,7 @@ describe ExternalIssue do context 'if issue id is a number' do let(:issue) { described_class.new('1234', project) } + it 'returns the issue ID prefixed by #' do expect(issue.reference_link_text).to eq '#1234' end diff --git a/spec/models/global_milestone_spec.rb b/spec/models/global_milestone_spec.rb index 9d901d01a52cd1cdb8356da358e9ef1d3c74a6e9..34dbdfec60d6580a4592e377e5b0774478b6ce6d 100644 --- a/spec/models/global_milestone_spec.rb +++ b/spec/models/global_milestone_spec.rb @@ -162,6 +162,7 @@ describe GlobalMilestone do describe '#initialize' do let(:milestone1_project1) { create(:milestone, title: "Milestone v1.2", project: project1) } + subject(:global_milestone) { described_class.new(milestone1_project1) } it 'has exactly one group milestone' do diff --git a/spec/models/group_group_link_spec.rb b/spec/models/group_group_link_spec.rb index e4ad5703a109717c614e0d6ff9a7d9e458a7df27..a877cc803ddacb2589b5bfd110300b688277b699 100644 --- a/spec/models/group_group_link_spec.rb +++ b/spec/models/group_group_link_spec.rb @@ -33,4 +33,12 @@ describe GroupGroupLink do validate_inclusion_of(:group_access).in_array(Gitlab::Access.values)) end end + + describe '#human_access' do + it 'delegates to Gitlab::Access' do + expect(Gitlab::Access).to receive(:human_access).with(group_group_link.group_access) + + group_group_link.human_access + end + end end diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb index 3fa9d71cc7d9348a4d8bbd3aa273d87b39d024cb..3531c695236ea5fc907b9969e4b9c57931ec6941 100644 --- a/spec/models/group_spec.rb +++ b/spec/models/group_spec.rb @@ -28,6 +28,7 @@ describe Group do describe '#members & #requesters' do let(:requester) { create(:user) } let(:developer) { create(:user) } + before do group.request_access(requester) group.add_developer(developer) @@ -1002,6 +1003,57 @@ describe Group do end end + describe '#related_group_ids' do + let(:nested_group) { create(:group, parent: group) } + let(:shared_with_group) { create(:group, parent: group) } + + before do + create(:group_group_link, shared_group: nested_group, + shared_with_group: shared_with_group) + end + + subject(:related_group_ids) { nested_group.related_group_ids } + + it 'returns id' do + expect(related_group_ids).to include(nested_group.id) + end + + it 'returns ancestor id' do + expect(related_group_ids).to include(group.id) + end + + it 'returns shared with group id' do + expect(related_group_ids).to include(shared_with_group.id) + end + + context 'with more than one ancestor group' do + let(:ancestor_group) { create(:group) } + + before do + group.update(parent: ancestor_group) + end + + it 'returns all ancestor group ids' do + expect(related_group_ids).to( + include(group.id, ancestor_group.id)) + end + end + + context 'with more than one shared with group' do + let(:another_shared_with_group) { create(:group, parent: group) } + + before do + create(:group_group_link, shared_group: nested_group, + shared_with_group: another_shared_with_group) + end + + it 'returns all shared with group ids' do + expect(related_group_ids).to( + include(shared_with_group.id, another_shared_with_group.id)) + end + end + end + context 'with uploads' do it_behaves_like 'model with uploads', true do let(:model_object) { create(:group, :with_avatar) } diff --git a/spec/models/hooks/web_hook_log_spec.rb b/spec/models/hooks/web_hook_log_spec.rb index 22aad2fab0a9170ef363eef04c384d81dfd7a671..3520720d9a4c77ae487df175d7b8034231057c80 100644 --- a/spec/models/hooks/web_hook_log_spec.rb +++ b/spec/models/hooks/web_hook_log_spec.rb @@ -53,16 +53,19 @@ describe WebHookLog do describe '2xx' do let(:status) { '200' } + it { expect(web_hook_log.success?).to be_truthy } end describe 'not 2xx' do let(:status) { '500' } + it { expect(web_hook_log.success?).to be_falsey } end describe 'internal erorr' do let(:status) { 'internal error' } + it { expect(web_hook_log.success?).to be_falsey } end end diff --git a/spec/models/import_failure_spec.rb b/spec/models/import_failure_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..d6574791a657fa777f9bd45c28723d349036fa6d --- /dev/null +++ b/spec/models/import_failure_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe ImportFailure do + describe "Associations" do + it { is_expected.to belong_to(:project) } + it { is_expected.to belong_to(:group) } + end + + describe 'Validations' do + context 'has no group' do + before do + allow(subject).to receive(:group).and_return(nil) + end + + it { is_expected.to validate_presence_of(:project) } + end + + context 'has no project' do + before do + allow(subject).to receive(:project).and_return(nil) + end + + it { is_expected.to validate_presence_of(:group) } + end + end +end diff --git a/spec/models/instance_configuration_spec.rb b/spec/models/instance_configuration_spec.rb index 439545118583e532e149c2662ce4aa7bba7abe91..3e0181b884637e01eede2355065d6ebe378e2655 100644 --- a/spec/models/instance_configuration_spec.rb +++ b/spec/models/instance_configuration_spec.rb @@ -48,6 +48,7 @@ describe InstanceConfiguration do describe '#gitlab_pages' do let(:gitlab_pages) { subject.settings[:gitlab_pages] } + it 'returns Settings.pages' do gitlab_pages.delete(:ip_address) @@ -73,6 +74,7 @@ describe InstanceConfiguration do describe '#gitlab_ci' do let(:gitlab_ci) { subject.settings[:gitlab_ci] } + it 'returns Settings.gitalb_ci' do gitlab_ci.delete(:artifacts_max_size) diff --git a/spec/models/internal_id_spec.rb b/spec/models/internal_id_spec.rb index c73ade3f896b76a9359bacff313ab01919e2f451..33d03bfc0f55553f707b6101ab9e731824a7e430 100644 --- a/spec/models/internal_id_spec.rb +++ b/spec/models/internal_id_spec.rb @@ -170,6 +170,7 @@ describe InternalId do describe '.track_greatest' do let(:value) { 9001 } + subject { described_class.track_greatest(issue, scope, usage, value, init) } context 'in the absence of a record' do @@ -210,6 +211,7 @@ describe InternalId do describe '#increment_and_save!' do let(:id) { create(:internal_id) } + subject { id.increment_and_save! } it 'returns incremented iid' do @@ -236,6 +238,7 @@ describe InternalId do describe '#track_greatest_and_save!' do let(:id) { create(:internal_id) } let(:new_last_value) { 9001 } + subject { id.track_greatest_and_save!(new_last_value) } it 'returns new last value' do diff --git a/spec/models/issue_spec.rb b/spec/models/issue_spec.rb index d1ed06dd04dcf3b087c8d617be533ffc736160ce..5c3f7c09e221785a9f5ccd2d24848a923ccf093e 100644 --- a/spec/models/issue_spec.rb +++ b/spec/models/issue_spec.rb @@ -259,6 +259,7 @@ describe Issue do describe '#can_move?' do let(:user) { create(:user) } let(:issue) { create(:issue) } + subject { issue.can_move?(user) } context 'user is not a member of project issue belongs to' do @@ -277,6 +278,7 @@ describe Issue do context 'issue not persisted' do let(:issue) { build(:issue, project: project) } + it { is_expected.to eq false } end @@ -306,6 +308,7 @@ describe Issue do describe '#moved?' do let(:issue) { create(:issue) } + subject { issue.moved? } context 'issue not moved' do @@ -322,6 +325,7 @@ describe Issue do describe '#duplicated?' do let(:issue) { create(:issue) } + subject { issue.duplicated? } context 'issue not duplicated' do @@ -380,6 +384,7 @@ describe Issue do describe '#has_related_branch?' do let(:issue) { create(:issue, title: "Blue Bell Knoll") } + subject { issue.has_related_branch? } context 'branch found' do @@ -442,6 +447,7 @@ describe Issue do describe '#can_be_worked_on?' do let(:project) { build(:project) } + subject { build(:issue, :opened, project: project) } context 'is closed' do diff --git a/spec/models/key_spec.rb b/spec/models/key_spec.rb index 2dd9583087f7ed99e4b2d2a6a123379a2b0535d0..1ae90cae4b158afb0fd46f2857628d94e40efcfe 100644 --- a/spec/models/key_spec.rb +++ b/spec/models/key_spec.rb @@ -25,6 +25,7 @@ describe Key, :mailer do describe "Methods" do let(:user) { create(:user) } + it { is_expected.to respond_to :projects } it { is_expected.to respond_to :publishable_key } diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb index bf6fa20dc178c6cdba59bb0ed96893bfbeeb65c8..c6894c04385fa6bfd6eae89f6b5efe20dc0ea1ca 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -110,6 +110,7 @@ describe MergeRequest do describe '#squash?' do let(:merge_request) { build(:merge_request, squash: squash) } + subject { merge_request.squash? } context 'disabled in database' do @@ -383,7 +384,7 @@ describe MergeRequest do end it 'returns target branches sort by updated at desc' do - expect(described_class.recent_target_branches).to match_array(['feature', 'merge-test', 'fix']) + expect(described_class.recent_target_branches).to match_array(%w[feature merge-test fix]) end end @@ -851,6 +852,7 @@ describe MergeRequest do describe '#modified_paths' do let(:paths) { double(:paths) } + subject(:merge_request) { build(:merge_request) } before do @@ -879,6 +881,7 @@ describe MergeRequest do context 'when no arguments provided' do let(:diff) { merge_request.merge_request_diff } + subject(:merge_request) { create(:merge_request, source_branch: 'feature', target_branch: 'master') } it 'returns affected file paths for merge_request_diff' do @@ -960,6 +963,15 @@ describe MergeRequest do expect(subject.closes_issues).to be_empty end + + it 'ignores referenced issues when auto-close is disabled' do + subject.project.update!(autoclose_referenced_issues: false) + + allow(subject.project).to receive(:default_branch) + .and_return(subject.target_branch) + + expect(subject.closes_issues).to be_empty + end end describe '#issues_mentioned_but_not_closing' do @@ -1554,6 +1566,7 @@ describe MergeRequest do describe '#calculate_reactive_cache' do let(:project) { create(:project, :repository) } let(:merge_request) { create(:merge_request, source_project: project) } + subject { merge_request.calculate_reactive_cache(service_class_name) } context 'when given an unknown service class name' do @@ -2009,7 +2022,7 @@ describe MergeRequest do it 'atomically enqueues a RebaseWorker job and updates rebase_jid' do expect(RebaseWorker) .to receive(:perform_async) - .with(merge_request.id, user_id) + .with(merge_request.id, user_id, false) .and_return(rebase_jid) expect(merge_request).to receive(:expire_etag_cache) @@ -2201,6 +2214,16 @@ describe MergeRequest do end end + describe "#actual_head_pipeline_active? " do + it do + is_expected + .to delegate_method(:active?) + .to(:actual_head_pipeline) + .with_prefix + .with_arguments(allow_nil: true) + end + end + describe '#mergeable_ci_state?' do let(:project) { create(:project, only_allow_merge_if_pipeline_succeeds: true) } let(:pipeline) { create(:ci_empty_pipeline) } @@ -2322,6 +2345,10 @@ describe MergeRequest do let(:project) { create(:project, :repository) } let(:user) { project.creator } let(:merge_request) { create(:merge_request, source_project: project) } + let(:source_branch) { merge_request.source_branch } + let(:target_branch) { merge_request.target_branch } + let(:source_oid) { project.commit(source_branch).id } + let(:target_oid) { project.commit(target_branch).id } before do merge_request.source_project.add_maintainer(user) @@ -2332,13 +2359,21 @@ describe MergeRequest do let(:environments) { create_list(:environment, 3, project: project) } before do - create(:deployment, :success, environment: environments.first, ref: 'master', sha: project.commit('master').id) - create(:deployment, :success, environment: environments.second, ref: 'feature', sha: project.commit('feature').id) + create(:deployment, :success, environment: environments.first, ref: source_branch, sha: source_oid) + create(:deployment, :success, environment: environments.second, ref: target_branch, sha: target_oid) end it 'selects deployed environments' do expect(merge_request.environments_for(user)).to contain_exactly(environments.first) end + + it 'selects latest deployed environment' do + latest_environment = create(:environment, project: project) + create(:deployment, :success, environment: latest_environment, ref: source_branch, sha: source_oid) + + expect(merge_request.environments_for(user)).to eq([environments.first, latest_environment]) + expect(merge_request.environments_for(user, latest: true)).to contain_exactly(latest_environment) + end end context 'with environments on source project' do @@ -3032,6 +3067,7 @@ describe MergeRequest do describe 'transition to cannot_be_merged' do let(:notification_service) { double(:notification_service) } let(:todo_service) { double(:todo_service) } + subject { create(:merge_request, state, merge_status: :unchecked) } before do @@ -3241,6 +3277,7 @@ describe MergeRequest do describe 'when merge_when_pipeline_succeeds? is true' do describe 'when merge user is author' do let(:user) { create(:user) } + subject do create(:merge_request, merge_when_pipeline_succeeds: true, @@ -3255,6 +3292,7 @@ describe MergeRequest do describe 'when merge user and author are different users' do let(:merge_user) { create(:user) } + subject do create(:merge_request, merge_when_pipeline_succeeds: true, diff --git a/spec/models/namespace_spec.rb b/spec/models/namespace_spec.rb index 2ba0d97792bf633734ea6e8340cf960e88236289..740385bbd54f3ecff16d35a5ed756241be44542a 100644 --- a/spec/models/namespace_spec.rb +++ b/spec/models/namespace_spec.rb @@ -26,6 +26,7 @@ describe Namespace do it { is_expected.to validate_presence_of(:path) } it { is_expected.to validate_length_of(:path).is_at_most(255) } it { is_expected.to validate_presence_of(:owner) } + it { is_expected.to validate_numericality_of(:max_artifacts_size).only_integer.is_greater_than(0) } it 'does not allow too deep nesting' do ancestors = (1..21).to_a @@ -922,6 +923,12 @@ describe Namespace do expect(group.emails_disabled?).to be_truthy end + + it 'does not query the db when there is no parent group' do + group = create(:group, emails_disabled: true) + + expect { group.emails_disabled? }.not_to exceed_query_limit(0) + end end context 'when a subgroup' do diff --git a/spec/models/project_deploy_token_spec.rb b/spec/models/project_deploy_token_spec.rb index 8c8924762bdeebf432c1b43b41b908149141fd77..0543bbdf2a8fac551057dce63c515282934aef50 100644 --- a/spec/models/project_deploy_token_spec.rb +++ b/spec/models/project_deploy_token_spec.rb @@ -5,6 +5,7 @@ require 'spec_helper' RSpec.describe ProjectDeployToken, type: :model do let(:project) { create(:project) } let(:deploy_token) { create(:deploy_token) } + subject(:project_deploy_token) { create(:project_deploy_token, project: project, deploy_token: deploy_token) } it { is_expected.to belong_to :project } diff --git a/spec/models/project_feature_spec.rb b/spec/models/project_feature_spec.rb index 9ce1b8fd895b225a59c72c002a117e2619111670..6a3338989551f733d13cdbb8d58f4d9b928f9e09 100644 --- a/spec/models/project_feature_spec.rb +++ b/spec/models/project_feature_spec.rb @@ -3,6 +3,8 @@ require 'spec_helper' describe ProjectFeature do + using RSpec::Parameterized::TableSyntax + let(:project) { create(:project) } let(:user) { create(:user) } @@ -121,13 +123,14 @@ describe ProjectFeature do end context 'public features' do - it "does not allow public for other than pages" do - features = %w(issues wiki builds merge_requests snippets repository) - project_feature = project.project_feature + features = %w(issues wiki builds merge_requests snippets repository) - features.each do |feature| + features.each do |feature| + it "does not allow public access level for #{feature}" do + project_feature = project.project_feature field = "#{feature}_access_level".to_sym project_feature.update_attribute(field, ProjectFeature::PUBLIC) + expect(project_feature.valid?).to be_falsy end end @@ -158,12 +161,13 @@ describe ProjectFeature do end describe 'default pages access level' do - subject { project.project_feature.pages_access_level } + subject { project_feature.pages_access_level } - before do + let(:project_feature) do # project factory overrides all values in project_feature after creation project.project_feature.destroy! project.build_project_feature.save! + project.project_feature end context 'when new project is private' do @@ -182,6 +186,14 @@ describe ProjectFeature do let(:project) { create(:project, :public) } it { is_expected.to eq(ProjectFeature::ENABLED) } + + context 'when access control is forced on the admin level' do + before do + allow(::Gitlab::Pages).to receive(:access_control_is_forced?).and_return(true) + end + + it { is_expected.to eq(ProjectFeature::PRIVATE) } + end end end @@ -189,53 +201,59 @@ describe ProjectFeature do it 'returns true if Pages access controll is not enabled' do stub_config(pages: { access_control: false }) - project_feature = described_class.new + project_feature = described_class.new(pages_access_level: described_class::PRIVATE) expect(project_feature.public_pages?).to eq(true) end - context 'Pages access control is enabled' do + context 'when Pages access control is enabled' do before do stub_config(pages: { access_control: true }) end - it 'returns true if Pages access level is public' do - project_feature = described_class.new(pages_access_level: described_class::PUBLIC) - - expect(project_feature.public_pages?).to eq(true) + where(:project_visibility, :pages_access_level, :result) do + :private | ProjectFeature::PUBLIC | true + :internal | ProjectFeature::PUBLIC | true + :internal | ProjectFeature::ENABLED | false + :public | ProjectFeature::ENABLED | true + :private | ProjectFeature::PRIVATE | false + :public | ProjectFeature::PRIVATE | false end - it 'returns true if Pages access level is enabled and the project is public' do - project = build(:project, :public) - - project_feature = described_class.new(project: project, pages_access_level: described_class::ENABLED) - - expect(project_feature.public_pages?).to eq(true) - end + with_them do + let(:project_feature) do + project = build(:project, project_visibility) + project_feature = project.project_feature + project_feature.update!(pages_access_level: pages_access_level) + project_feature + end - it 'returns false if pages or the project are not public' do - project = build(:project, :private) + it 'properly handles project and Pages visibility settings' do + expect(project_feature.public_pages?).to eq(result) + end - project_feature = described_class.new(project: project, pages_access_level: described_class::ENABLED) + it 'returns false if access_control is forced on the admin level' do + stub_application_setting(force_pages_access_control: true) - expect(project_feature.public_pages?).to eq(false) + expect(project_feature.public_pages?).to eq(false) + end end end + end - describe '#private_pages?' do - subject(:project_feature) { described_class.new } + describe '#private_pages?' do + subject(:project_feature) { described_class.new } - it 'returns false if public_pages? is true' do - expect(project_feature).to receive(:public_pages?).and_return(true) + it 'returns false if public_pages? is true' do + expect(project_feature).to receive(:public_pages?).and_return(true) - expect(project_feature.private_pages?).to eq(false) - end + expect(project_feature.private_pages?).to eq(false) + end - it 'returns true if public_pages? is false' do - expect(project_feature).to receive(:public_pages?).and_return(false) + it 'returns true if public_pages? is false' do + expect(project_feature).to receive(:public_pages?).and_return(false) - expect(project_feature.private_pages?).to eq(true) - end + expect(project_feature.private_pages?).to eq(true) end end diff --git a/spec/models/project_services/chat_message/base_message_spec.rb b/spec/models/project_services/chat_message/base_message_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..8f80cf0b0747fc9037e35da031e50811eafeaa4a --- /dev/null +++ b/spec/models/project_services/chat_message/base_message_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe ChatMessage::BaseMessage do + let(:base_message) { described_class.new(args) } + let(:args) { { project_url: 'https://gitlab-domain.com' } } + + describe '#fallback' do + subject { base_message.fallback } + + before do + allow(base_message).to receive(:message).and_return(message) + end + + context 'without relative links' do + let(:message) { 'Just another *markdown* message' } + + it { is_expected.to eq(message) } + end + + context 'with relative links' do + let(:message) { 'Check this out ' } + + it { is_expected.to eq('Check this out https://gitlab-domain.com/uploads/Screenshot1.png') } + end + + context 'with multiple relative links' do + let(:message) { 'Check this out . And this ' } + + it { is_expected.to eq('Check this out https://gitlab-domain.com/uploads/Screenshot1.png. And this https://gitlab-domain.com/uploads/Screenshot2.png') } + end + end +end diff --git a/spec/models/project_services/chat_message/wiki_page_message_spec.rb b/spec/models/project_services/chat_message/wiki_page_message_spec.rb index c3db516f253950127e52fd44e2dcf4ee20d40ca0..1346a43335e5d576936291f00f0fe7222a9a6bf7 100644 --- a/spec/models/project_services/chat_message/wiki_page_message_spec.rb +++ b/spec/models/project_services/chat_message/wiki_page_message_spec.rb @@ -17,7 +17,8 @@ describe ChatMessage::WikiPageMessage do object_attributes: { title: 'Wiki page title', url: 'http://url.com', - content: 'Wiki page description' + content: 'Wiki page content', + message: 'Wiki page commit message' } } end @@ -57,10 +58,10 @@ describe ChatMessage::WikiPageMessage do args[:object_attributes][:action] = 'create' end - it 'returns the attachment for a new wiki page' do + it 'returns the commit message for a new wiki page' do expect(subject.attachments).to eq([ { - text: "Wiki page description", + text: "Wiki page commit message", color: color } ]) @@ -72,10 +73,10 @@ describe ChatMessage::WikiPageMessage do args[:object_attributes][:action] = 'update' end - it 'returns the attachment for an updated wiki page' do + it 'returns the commit message for an updated wiki page' do expect(subject.attachments).to eq([ { - text: "Wiki page description", + text: "Wiki page commit message", color: color } ]) @@ -119,8 +120,8 @@ describe ChatMessage::WikiPageMessage do args[:object_attributes][:action] = 'create' end - it 'returns the attachment for a new wiki page' do - expect(subject.attachments).to eq('Wiki page description') + it 'returns the commit message for a new wiki page' do + expect(subject.attachments).to eq('Wiki page commit message') end end @@ -129,8 +130,8 @@ describe ChatMessage::WikiPageMessage do args[:object_attributes][:action] = 'update' end - it 'returns the attachment for an updated wiki page' do - expect(subject.attachments).to eq('Wiki page description') + it 'returns the commit message for an updated wiki page' do + expect(subject.attachments).to eq('Wiki page commit message') end end end diff --git a/spec/models/project_services/emails_on_push_service_spec.rb b/spec/models/project_services/emails_on_push_service_spec.rb index ffe241aa88050233598081991be869ce3f248a69..56f094ecb48630f1e1ba96f80713c1af650baae6 100644 --- a/spec/models/project_services/emails_on_push_service_spec.rb +++ b/spec/models/project_services/emails_on_push_service_spec.rb @@ -25,19 +25,75 @@ describe EmailsOnPushService do let(:push_data) { { object_kind: 'push' } } let(:project) { create(:project, :repository) } let(:service) { create(:emails_on_push_service, project: project) } + let(:recipients) { 'test@gitlab.com' } - it 'does not send emails when disabled' do - expect(project).to receive(:emails_disabled?).and_return(true) - expect(EmailsOnPushWorker).not_to receive(:perform_async) + before do + subject.recipients = recipients + end + + shared_examples 'sending email' do |branches_to_be_notified, branch_being_pushed_to| + let(:push_data) { { object_kind: 'push', object_attributes: { ref: branch_being_pushed_to } } } - service.execute(push_data) + before do + subject.branches_to_be_notified = branches_to_be_notified + end + + it 'sends email' do + expect(EmailsOnPushWorker).not_to receive(:perform_async) + + service.execute(push_data) + end end - it 'does send emails when enabled' do - expect(project).to receive(:emails_disabled?).and_return(false) - expect(EmailsOnPushWorker).to receive(:perform_async) + shared_examples 'not sending email' do |branches_to_be_notified, branch_being_pushed_to| + let(:push_data) { { object_kind: 'push', object_attributes: { ref: branch_being_pushed_to } } } - service.execute(push_data) + before do + subject.branches_to_be_notified = branches_to_be_notified + end + + it 'does not send email' do + expect(EmailsOnPushWorker).not_to receive(:perform_async) + + service.execute(push_data) + end + end + + context 'when emails are disabled on the project' do + it 'does not send emails' do + expect(project).to receive(:emails_disabled?).and_return(true) + expect(EmailsOnPushWorker).not_to receive(:perform_async) + + service.execute(push_data) + end + end + + context 'when emails are enabled on the project' do + before do + create(:protected_branch, project: project, name: 'a-protected-branch') + expect(project).to receive(:emails_disabled?).and_return(true) + end + + using RSpec::Parameterized::TableSyntax + + where(:case_name, :branches_to_be_notified, :branch_being_pushed_to, :expected_action) do + 'pushing to a random branch and notification configured for all branches' | 'all' | 'random' | 'sending email' + 'pushing to the default branch and notification configured for all branches' | 'all' | 'master' | 'sending email' + 'pushing to a protected branch and notification configured for all branches' | 'all' | 'a-protected-branch' | 'sending email' + 'pushing to a random branch and notification configured for default branch only' | 'default' | 'random' | 'not sending email' + 'pushing to the default branch and notification configured for default branch only' | 'default' | 'master' | 'sending email' + 'pushing to a protected branch and notification configured for default branch only' | 'default' | 'a-protected-branch' | 'not sending email' + 'pushing to a random branch and notification configured for protected branches only' | 'protected' | 'random' | 'not sending email' + 'pushing to the default branch and notification configured for protected branches only' | 'protected' | 'master' | 'not sending email' + 'pushing to a protected branch and notification configured for protected branches only' | 'protected' | 'a-protected-branch' | 'sending email' + 'pushing to a random branch and notification configured for default and protected branches only' | 'default_and_protected' | 'random' | 'not sending email' + 'pushing to the default branch and notification configured for default and protected branches only' | 'default_and_protected' | 'master' | 'sending email' + 'pushing to a protected branch and notification configured for default and protected branches only' | 'default_and_protected' | 'a-protected-branch' | 'sending email' + end + + with_them do + include_examples params[:expected_action], branches_to_be_notified: params[:branches_to_be_notified], branch_being_pushed_to: params[:branch_being_pushed_to] + end end end end diff --git a/spec/models/project_services/external_wiki_service_spec.rb b/spec/models/project_services/external_wiki_service_spec.rb index bdd8605436fa07154a1fdd28fddfb8736eabb50e..f8d88a944a5da6e49f36e12a0a45e4f25fc3a147 100644 --- a/spec/models/project_services/external_wiki_service_spec.rb +++ b/spec/models/project_services/external_wiki_service_spec.rb @@ -26,4 +26,34 @@ describe ExternalWikiService do it { is_expected.not_to validate_presence_of(:external_wiki_url) } end end + + describe 'test' do + before do + subject.properties['external_wiki_url'] = url + end + + let(:url) { 'http://foo' } + let(:data) { nil } + let(:result) { subject.test(data) } + + context 'the URL is not reachable' do + before do + WebMock.stub_request(:get, url).to_return(status: 404, body: 'not a page') + end + + it 'is not successful' do + expect(result[:success]).to be_falsey + end + end + + context 'the URL is reachable' do + before do + WebMock.stub_request(:get, url).to_return(status: 200, body: 'foo') + end + + it 'is successful' do + expect(result[:success]).to be_truthy + end + end + end end diff --git a/spec/models/project_services/microsoft_teams_service_spec.rb b/spec/models/project_services/microsoft_teams_service_spec.rb index 275244fa5fde2dfafb9a7c29efae3abe4c4918da..83d3c8b3a70c7844f632591fc80ae4ea66f1598b 100644 --- a/spec/models/project_services/microsoft_teams_service_spec.rb +++ b/spec/models/project_services/microsoft_teams_service_spec.rb @@ -38,6 +38,7 @@ describe MicrosoftTeamsService do describe "#execute" do let(:user) { create(:user) } + set(:project) { create(:project, :repository, :wiki_repo) } before do diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index d55530bf82004639b4862ffd672eb44737ba3aae..c57f47b5738fc9e6ad1e487b0fbaaa58feeb97c0 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -165,6 +165,7 @@ describe Project do let(:project) { create(:project, :public) } let(:requester) { create(:user) } let(:developer) { create(:user) } + before do project.request_access(requester) project.add_developer(developer) @@ -210,6 +211,7 @@ describe Project do it { is_expected.to validate_presence_of(:creator) } it { is_expected.to validate_presence_of(:namespace) } it { is_expected.to validate_presence_of(:repository_storage) } + it { is_expected.to validate_numericality_of(:max_artifacts_size).only_integer.is_greater_than(0) } it 'validates build timeout constraints' do is_expected.to validate_numericality_of(:build_timeout) @@ -472,6 +474,32 @@ describe Project do end end + describe '#autoclose_referenced_issues' do + context 'when DB entry is nil' do + let(:project) { create(:project, autoclose_referenced_issues: nil) } + + it 'returns true' do + expect(project.autoclose_referenced_issues).to be_truthy + end + end + + context 'when DB entry is true' do + let(:project) { create(:project, autoclose_referenced_issues: true) } + + it 'returns true' do + expect(project.autoclose_referenced_issues).to be_truthy + end + end + + context 'when DB entry is false' do + let(:project) { create(:project, autoclose_referenced_issues: false) } + + it 'returns false' do + expect(project.autoclose_referenced_issues).to be_falsey + end + end + end + describe 'project token' do it 'sets an random token if none provided' do project = FactoryBot.create(:project, runners_token: '') @@ -815,6 +843,7 @@ describe Project do context 'with external issues tracker' do let!(:internal_issue) { create(:issue, project: project) } + before do allow(project).to receive(:external_issue_tracker).and_return(true) end @@ -1319,9 +1348,7 @@ describe Project do let(:project2) { create(:project, :public, group: group) } before do - 2.times do - create(:note_on_commit, project: project1) - end + create_list(:note_on_commit, 2, project: project1) create(:note_on_commit, project: project2) @@ -1335,9 +1362,7 @@ describe Project do end it 'does not take system notes into account' do - 10.times do - create(:note_on_commit, project: project2, system: true) - end + create_list(:note_on_commit, 10, project: project2, system: true) expect(described_class.trending.to_a).to eq([project1, project2]) end @@ -2334,6 +2359,7 @@ describe Project do describe '#has_remote_mirror?' do let(:project) { create(:project, :remote_mirror, :import_started) } + subject { project.has_remote_mirror? } before do @@ -2353,6 +2379,7 @@ describe Project do describe '#update_remote_mirrors' do let(:project) { create(:project, :remote_mirror, :import_started) } + delegate :update_remote_mirrors, to: :project before do @@ -3460,6 +3487,7 @@ describe Project do describe '#pipeline_status' do let(:project) { create(:project, :repository) } + it 'builds a pipeline status' do expect(project.pipeline_status).to be_a(Gitlab::Cache::Ci::ProjectPipelineStatus) end @@ -4638,6 +4666,7 @@ describe Project do describe '#execute_hooks' do let(:data) { { ref: 'refs/heads/master', data: 'data' } } + it 'executes active projects hooks with the specified scope' do hook = create(:project_hook, merge_requests_events: false, push_events: true) expect(ProjectHook).to receive(:select_active) @@ -4716,7 +4745,7 @@ describe Project do end it 'returns true when a plugin exists' do - expect(Gitlab::Plugin).to receive(:any?).twice.and_return(true) + expect(Gitlab::FileHook).to receive(:any?).twice.and_return(true) expect(project.has_active_hooks?(:merge_request_events)).to be_truthy expect(project.has_active_hooks?).to be_truthy @@ -4975,6 +5004,7 @@ describe Project do context 'when there is a gitlab deploy token associated but is has been revoked' do let!(:deploy_token) { create(:deploy_token, :gitlab_deploy_token, :revoked, projects: [project]) } + it { is_expected.to be_nil } end @@ -5018,6 +5048,7 @@ describe Project do context '#members_among' do let(:users) { create_list(:user, 3) } + set(:group) { create(:group) } set(:project) { create(:project, namespace: group) } @@ -5105,7 +5136,7 @@ describe Project do describe '.deployments' do subject { project.deployments } - let(:project) { create(:project) } + let(:project) { create(:project, :repository) } before do allow_any_instance_of(Deployment).to receive(:create_ref) diff --git a/spec/models/readme_blob_spec.rb b/spec/models/readme_blob_spec.rb index f07713bd908a69d625bea34bd4c69b89b22fb21c..34182fa413fe5ae7afacc3a3e63e872b830fed38 100644 --- a/spec/models/readme_blob_spec.rb +++ b/spec/models/readme_blob_spec.rb @@ -7,6 +7,7 @@ describe ReadmeBlob do describe 'policy' do let(:project) { build(:project, :repository) } + subject { described_class.new(fake_blob(path: 'README.md'), project.repository) } it 'works with policy' do diff --git a/spec/models/release_spec.rb b/spec/models/release_spec.rb index cadb8793e150880e3cbf5622fd1ce76005169397..2f84b92b806b63f9c20facb7f161bf3b0ef81c22 100644 --- a/spec/models/release_spec.rb +++ b/spec/models/release_spec.rb @@ -181,4 +181,10 @@ RSpec.describe Release do it { is_expected.to eq(release.evidence.summary) } end end + + describe '#milestone_titles' do + let(:release) { create(:release, :with_milestones) } + + it { expect(release.milestone_titles).to eq(release.milestones.map {|m| m.title }.sort.join(", "))} + end end diff --git a/spec/models/repository_spec.rb b/spec/models/repository_spec.rb index c0245dfdf1afac9acea7bb7683675a91ba832597..38f3777c902224f0e3cee2aadff3e61301c9d13d 100644 --- a/spec/models/repository_spec.rb +++ b/spec/models/repository_spec.rb @@ -717,6 +717,7 @@ describe Repository do describe "search_files_by_content" do let(:results) { repository.search_files_by_content('feature', 'master') } + subject { results } it { is_expected.to be_an Array } @@ -1330,6 +1331,13 @@ describe Repository do repository.root_ref end + it 'returns nil if the repository does not exist' do + repository = create(:project).repository + + expect(repository).not_to be_exists + expect(repository.root_ref).to be_nil + end + it_behaves_like 'asymmetric cached method', :root_ref end diff --git a/spec/models/resource_weight_event_spec.rb b/spec/models/resource_weight_event_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..2f00204512e5c780472e3158e3dda6304a9b067b --- /dev/null +++ b/spec/models/resource_weight_event_spec.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ResourceWeightEvent, type: :model do + let_it_be(:user1) { create(:user) } + let_it_be(:user2) { create(:user) } + + let_it_be(:issue1) { create(:issue, author: user1) } + let_it_be(:issue2) { create(:issue, author: user1) } + let_it_be(:issue3) { create(:issue, author: user2) } + + describe 'validations' do + it { is_expected.not_to allow_value(nil).for(:user) } + it { is_expected.not_to allow_value(nil).for(:issue) } + it { is_expected.to allow_value(nil).for(:weight) } + end + + describe 'associations' do + it { is_expected.to belong_to(:user) } + it { is_expected.to belong_to(:issue) } + end + + describe '.by_issue' do + let_it_be(:event1) { create(:resource_weight_event, issue: issue1) } + let_it_be(:event2) { create(:resource_weight_event, issue: issue2) } + let_it_be(:event3) { create(:resource_weight_event, issue: issue1) } + + it 'returns the expected records for an issue with events' do + events = ResourceWeightEvent.by_issue(issue1) + + expect(events).to contain_exactly(event1, event3) + end + + it 'returns the expected records for an issue with no events' do + events = ResourceWeightEvent.by_issue(issue3) + + expect(events).to be_empty + end + end + + describe '.created_after' do + let!(:created_at1) { 1.day.ago } + let!(:created_at2) { 2.days.ago } + let!(:created_at3) { 3.days.ago } + + let!(:event1) { create(:resource_weight_event, issue: issue1, created_at: created_at1) } + let!(:event2) { create(:resource_weight_event, issue: issue2, created_at: created_at2) } + let!(:event3) { create(:resource_weight_event, issue: issue2, created_at: created_at3) } + + it 'returns the expected events' do + events = ResourceWeightEvent.created_after(created_at3) + + expect(events).to contain_exactly(event1, event2) + end + + it 'returns no events if time is after last record time' do + events = ResourceWeightEvent.created_after(1.minute.ago) + + expect(events).to be_empty + end + end + + describe '#discussion_id' do + let_it_be(:event) { create(:resource_weight_event, issue: issue1, created_at: Time.utc(2019, 12, 30)) } + + it 'returns the expected id' do + allow(Digest::SHA1).to receive(:hexdigest) + .with("ResourceWeightEvent-2019-12-30 00:00:00 UTC-#{user1.id}") + .and_return('73d167c478') + + expect(event.discussion_id).to eq('73d167c478') + end + end +end diff --git a/spec/models/sent_notification_spec.rb b/spec/models/sent_notification_spec.rb index 09be90b82ede8e410f76cac904e013786ac103e3..7539bf1e957c9c0ec331f33c749e444e01026a24 100644 --- a/spec/models/sent_notification_spec.rb +++ b/spec/models/sent_notification_spec.rb @@ -18,6 +18,7 @@ describe SentNotification do context "when the project doesn't match the discussion project" do let(:discussion_id) { create(:note).discussion_id } + subject { build(:sent_notification, in_reply_to_discussion_id: discussion_id) } it "is invalid" do @@ -29,6 +30,7 @@ describe SentNotification do let(:project) { create(:project, :repository) } let(:issue) { create(:issue, project: project) } let(:discussion_id) { create(:note, project: project, noteable: issue).discussion_id } + subject { build(:sent_notification, project: project, noteable: issue, in_reply_to_discussion_id: discussion_id) } it "is valid" do @@ -196,6 +198,7 @@ describe SentNotification do describe '#create_reply' do context 'for issue' do let(:issue) { create(:issue) } + subject { described_class.record(issue, issue.author.id) } it 'creates a comment on the issue' do @@ -206,6 +209,7 @@ describe SentNotification do context 'for issue comment' do let(:note) { create(:note_on_issue) } + subject { described_class.record_note(note, note.author.id) } it 'creates a comment on the issue' do @@ -217,6 +221,7 @@ describe SentNotification do context 'for issue discussion' do let(:note) { create(:discussion_note_on_issue) } + subject { described_class.record_note(note, note.author.id) } it 'creates a reply on the discussion' do @@ -228,6 +233,7 @@ describe SentNotification do context 'for merge request' do let(:merge_request) { create(:merge_request) } + subject { described_class.record(merge_request, merge_request.author.id) } it 'creates a comment on the merge_request' do @@ -238,6 +244,7 @@ describe SentNotification do context 'for merge request comment' do let(:note) { create(:note_on_merge_request) } + subject { described_class.record_note(note, note.author.id) } it 'creates a comment on the merge request' do @@ -249,6 +256,7 @@ describe SentNotification do context 'for merge request diff discussion' do let(:note) { create(:diff_note_on_merge_request) } + subject { described_class.record_note(note, note.author.id) } it 'creates a reply on the discussion' do @@ -260,6 +268,7 @@ describe SentNotification do context 'for merge request non-diff discussion' do let(:note) { create(:discussion_note_on_merge_request) } + subject { described_class.record_note(note, note.author.id) } it 'creates a reply on the discussion' do @@ -272,6 +281,7 @@ describe SentNotification do context 'for commit' do let(:project) { create(:project, :repository) } let(:commit) { project.commit } + subject { described_class.record(commit, project.creator.id) } it 'creates a comment on the commit' do @@ -282,6 +292,7 @@ describe SentNotification do context 'for commit comment' do let(:note) { create(:note_on_commit) } + subject { described_class.record_note(note, note.author.id) } it 'creates a comment on the commit' do @@ -293,6 +304,7 @@ describe SentNotification do context 'for commit diff discussion' do let(:note) { create(:diff_note_on_commit) } + subject { described_class.record_note(note, note.author.id) } it 'creates a reply on the discussion' do @@ -304,6 +316,7 @@ describe SentNotification do context 'for commit non-diff discussion' do let(:note) { create(:discussion_note_on_commit) } + subject { described_class.record_note(note, note.author.id) } it 'creates a reply on the discussion' do diff --git a/spec/models/sentry_issue_spec.rb b/spec/models/sentry_issue_spec.rb index 48f9adf64afe56b67e4cc430bd49f14de7ff36de..7dc1cea4617b8b679c91eb15b7308c128e36b43b 100644 --- a/spec/models/sentry_issue_spec.rb +++ b/spec/models/sentry_issue_spec.rb @@ -13,6 +13,16 @@ describe SentryIssue do it { is_expected.to validate_presence_of(:issue) } it { is_expected.to validate_uniqueness_of(:issue) } it { is_expected.to validate_presence_of(:sentry_issue_identifier) } - it { is_expected.to validate_uniqueness_of(:sentry_issue_identifier).with_message("has already been taken") } + end + + describe '.for_project_and_identifier' do + let!(:sentry_issue) { create(:sentry_issue) } + let(:project) { sentry_issue.issue.project } + let(:identifier) { sentry_issue.sentry_issue_identifier } + let!(:second_sentry_issue) { create(:sentry_issue) } + + subject { described_class.for_project_and_identifier(project, identifier) } + + it { is_expected.to eq(sentry_issue) } end end diff --git a/spec/models/snippet_spec.rb b/spec/models/snippet_spec.rb index 9c549a6d56d1e02936be294513a3617bc445b72a..ae43c0d585a83e952c4f8289119c0d169142147c 100644 --- a/spec/models/snippet_spec.rb +++ b/spec/models/snippet_spec.rb @@ -141,6 +141,7 @@ describe Snippet do describe "#content_html_invalidated?" do let(:snippet) { create(:snippet, content: "md", content_html: "html", file_name: "foo.md") } + it "invalidates the HTML cache of content when the filename changes" do expect { snippet.file_name = "foo.rb" }.to change { snippet.content_html_invalidated? }.from(false).to(true) end diff --git a/spec/models/trending_project_spec.rb b/spec/models/trending_project_spec.rb index 619fc8e7d38846ea73a8d2ff2d51991d50895992..4a248b715741b0135c720502a35362c40f7e20ba 100644 --- a/spec/models/trending_project_spec.rb +++ b/spec/models/trending_project_spec.rb @@ -11,13 +11,9 @@ describe TrendingProject do let(:internal_project) { create(:project, :internal) } before do - 3.times do - create(:note_on_commit, project: public_project1) - end + create_list(:note_on_commit, 3, project: public_project1) - 2.times do - create(:note_on_commit, project: public_project2) - end + create_list(:note_on_commit, 2, project: public_project2) create(:note_on_commit, project: public_project3, created_at: 5.weeks.ago) create(:note_on_commit, project: private_project) diff --git a/spec/models/uploads/fog_spec.rb b/spec/models/uploads/fog_spec.rb index b93d9449da9ae7644f30006c05718e9148f9d202..72a169280af74c343a2fc8b918f10542bd52301c 100644 --- a/spec/models/uploads/fog_spec.rb +++ b/spec/models/uploads/fog_spec.rb @@ -31,6 +31,7 @@ describe Uploads::Fog do describe '#keys' do let!(:uploads) { create_list(:upload, 2, :object_storage, uploader: FileUploader, model: project) } + subject { data_store.keys(relation) } it 'returns keys' do @@ -41,6 +42,7 @@ describe Uploads::Fog do 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 diff --git a/spec/models/uploads/local_spec.rb b/spec/models/uploads/local_spec.rb index 3468399f3704219e6019eae1d191db9e2abcb834..374c3019edc9cb92c5e5acc268fb2bc7937fde25 100644 --- a/spec/models/uploads/local_spec.rb +++ b/spec/models/uploads/local_spec.rb @@ -15,6 +15,7 @@ describe Uploads::Local do describe '#keys' do let!(:uploads) { create_list(:upload, 2, uploader: FileUploader, model: project) } + subject { data_store.keys(relation) } it 'returns keys' do @@ -25,6 +26,7 @@ describe Uploads::Local do 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 diff --git a/spec/models/user_interacted_project_spec.rb b/spec/models/user_interacted_project_spec.rb index b96ff08e22d48c65b6ba1639919fa0bc5d3c3af4..e2c485343ae5022b1519a5b1155f4029a2b40005 100644 --- a/spec/models/user_interacted_project_spec.rb +++ b/spec/models/user_interacted_project_spec.rb @@ -11,6 +11,7 @@ describe UserInteractedProject do Event::ACTIONS.each do |action| context "for all actions (event types)" do let(:event) { build(:event, action: action) } + it 'creates a record' do expect { subject }.to change { described_class.count }.from(0).to(1) end diff --git a/spec/models/user_preference_spec.rb b/spec/models/user_preference_spec.rb index e09c91e874a16fc13fa9e31b9a770c836b6ecd30..bb88983e140376e447fbb61775c7115691e56bb4 100644 --- a/spec/models/user_preference_spec.rb +++ b/spec/models/user_preference_spec.rb @@ -5,6 +5,12 @@ require 'spec_helper' describe UserPreference do let(:user_preference) { create(:user_preference) } + describe 'notes filters global keys' do + it 'contains expected values' do + expect(UserPreference::NOTES_FILTERS.keys).to match_array([:all_notes, :only_comments, :only_activity]) + end + end + describe '#set_notes_filter' do let(:issuable) { build_stubbed(:issue) } diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 58aa945bff0e1069e9973ac7f2b8f0edb4ad40a8..5620f211d9ca506ae4253add52eba67e91f5ab60 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -147,15 +147,15 @@ describe User, :do_not_mock_admin_mode do describe 'name' do it { is_expected.to validate_presence_of(:name) } - it { is_expected.to validate_length_of(:name).is_at_most(128) } + it { is_expected.to validate_length_of(:name).is_at_most(255) } end describe 'first name' do - it { is_expected.to validate_length_of(:first_name).is_at_most(255) } + it { is_expected.to validate_length_of(:first_name).is_at_most(127) } end describe 'last name' do - it { is_expected.to validate_length_of(:last_name).is_at_most(255) } + it { is_expected.to validate_length_of(:last_name).is_at_most(127) } end describe 'username' do @@ -633,6 +633,27 @@ describe User, :do_not_mock_admin_mode do end end end + + describe '.active_without_ghosts' do + let_it_be(:user1) { create(:user, :external) } + let_it_be(:user2) { create(:user, state: 'blocked') } + let_it_be(:user3) { create(:user, ghost: true) } + let_it_be(:user4) { create(:user) } + + it 'returns all active users but ghost users' do + expect(described_class.active_without_ghosts).to match_array([user1, user4]) + end + end + + describe '.without_ghosts' do + let_it_be(:user1) { create(:user, :external) } + let_it_be(:user2) { create(:user, state: 'blocked') } + let_it_be(:user3) { create(:user, ghost: true) } + + it 'returns users without ghosts users' do + expect(described_class.without_ghosts).to match_array([user1, user2]) + end + end end describe "Respond to" do @@ -1252,7 +1273,7 @@ describe User, :do_not_mock_admin_mode do let(:user) { double } it 'filters by active users by default' do - expect(described_class).to receive(:active).and_return([user]) + expect(described_class).to receive(:active_without_ghosts).and_return([user]) expect(described_class.filter_items(nil)).to include user end @@ -1991,6 +2012,19 @@ describe User, :do_not_mock_admin_mode do expect(user.blocked?).to be_truthy expect(user.ldap_blocked?).to be_truthy end + + context 'on a read-only instance' do + before do + allow(Gitlab::Database).to receive(:read_only?).and_return(true) + end + + it 'does not block user' do + user.ldap_block + + expect(user.blocked?).to be_falsey + expect(user.ldap_blocked?).to be_falsey + end + end end end @@ -2390,6 +2424,7 @@ describe User, :do_not_mock_admin_mode do describe '#authorizations_for_projects' do let!(:user) { create(:user) } + subject { Project.where("EXISTS (?)", user.authorizations_for_projects) } it 'includes projects that belong to a user, but no other projects' do @@ -3700,6 +3735,7 @@ describe User, :do_not_mock_admin_mode do describe '#required_terms_not_accepted?' do let(:user) { build(:user) } + subject { user.required_terms_not_accepted? } context "when terms are not enforced" do diff --git a/spec/policies/ci/trigger_policy_spec.rb b/spec/policies/ci/trigger_policy_spec.rb index e936277a3913ddec5819d13914f1420167da2c46..28e5a2b2cd63747a5c6b14be58b853b3e3fb0e24 100644 --- a/spec/policies/ci/trigger_policy_spec.rb +++ b/spec/policies/ci/trigger_policy_spec.rb @@ -10,60 +10,6 @@ describe Ci::TriggerPolicy do subject { described_class.new(user, trigger) } describe '#rules' do - context 'when owner is undefined' do - before do - stub_feature_flags(use_legacy_pipeline_triggers: false) - trigger.update_attribute(:owner, nil) - end - - context 'when user is maintainer of the project' do - before do - project.add_maintainer(user) - end - - it { is_expected.to be_allowed(:manage_trigger) } - it { is_expected.not_to be_allowed(:admin_trigger) } - end - - context 'when user is developer of the project' do - before do - project.add_developer(user) - end - - it { is_expected.not_to be_allowed(:manage_trigger) } - it { is_expected.not_to be_allowed(:admin_trigger) } - end - - context 'when :use_legacy_pipeline_triggers feature flag is enabled' do - before do - stub_feature_flags(use_legacy_pipeline_triggers: true) - end - - context 'when user is maintainer of the project' do - before do - project.add_maintainer(user) - end - - it { is_expected.to be_allowed(:manage_trigger) } - it { is_expected.to be_allowed(:admin_trigger) } - end - - context 'when user is developer of the project' do - before do - project.add_developer(user) - end - - it { is_expected.not_to be_allowed(:manage_trigger) } - it { is_expected.not_to be_allowed(:admin_trigger) } - end - - context 'when user is not member of the project' do - it { is_expected.not_to be_allowed(:manage_trigger) } - it { is_expected.not_to be_allowed(:admin_trigger) } - end - end - end - context 'when owner is an user' do before do trigger.update!(owner: user) diff --git a/spec/policies/project_policy_spec.rb b/spec/policies/project_policy_spec.rb index 188eafadfc13caecabd2f83c274d2d63de70d9be..e47204c774b4240758f3e86312875b8ab94a8365 100644 --- a/spec/policies/project_policy_spec.rb +++ b/spec/policies/project_policy_spec.rb @@ -508,6 +508,34 @@ describe ProjectPolicy do end end + context 'forking a project' do + subject { described_class.new(current_user, project) } + + context 'anonymous user' do + let(:current_user) { nil } + + it { is_expected.to be_disallowed(:fork_project) } + end + + context 'project member' do + let_it_be(:project) { create(:project, :private) } + + context 'guest' do + let(:current_user) { guest } + + it { is_expected.to be_disallowed(:fork_project) } + end + + %w(reporter developer maintainer).each do |role| + context role do + let(:current_user) { send(role) } + + it { is_expected.to be_allowed(:fork_project) } + end + end + end + end + describe 'update_max_artifacts_size' do subject { described_class.new(current_user, project) } diff --git a/spec/presenters/ci/build_runner_presenter_spec.rb b/spec/presenters/ci/build_runner_presenter_spec.rb index 017e94d04f192170db1c9ed907c279b7f9cffc96..0635c3189424aa9f20641a624c133603c5494323 100644 --- a/spec/presenters/ci/build_runner_presenter_spec.rb +++ b/spec/presenters/ci/build_runner_presenter_spec.rb @@ -183,29 +183,81 @@ describe Ci::BuildRunnerPresenter do let(:pipeline) { merge_request.all_pipelines.first } let(:build) { create(:ci_build, ref: pipeline.ref, pipeline: pipeline) } - it 'returns the correct refspecs' do - is_expected - .to contain_exactly('+refs/merge-requests/1/head:refs/merge-requests/1/head') - end - - context 'when GIT_DEPTH is zero' do + context 'when depend_on_persistent_pipeline_ref feature flag is enabled' do before do - create(:ci_pipeline_variable, key: 'GIT_DEPTH', value: 0, pipeline: build.pipeline) + stub_feature_flags(ci_force_exposing_merge_request_refs: false) + pipeline.persistent_ref.create end it 'returns the correct refspecs' do is_expected - .to contain_exactly('+refs/merge-requests/1/head:refs/merge-requests/1/head', - '+refs/heads/*:refs/remotes/origin/*', - '+refs/tags/*:refs/tags/*') + .to contain_exactly("+refs/pipelines/#{pipeline.id}:refs/pipelines/#{pipeline.id}") + end + + context 'when ci_force_exposing_merge_request_refs feature flag is enabled' do + before do + stub_feature_flags(ci_force_exposing_merge_request_refs: true) + end + + it 'returns the correct refspecs' do + is_expected + .to contain_exactly("+refs/pipelines/#{pipeline.id}:refs/pipelines/#{pipeline.id}", + '+refs/merge-requests/1/head:refs/merge-requests/1/head') + end + end + + context 'when GIT_DEPTH is zero' do + before do + create(:ci_pipeline_variable, key: 'GIT_DEPTH', value: 0, pipeline: build.pipeline) + end + + it 'returns the correct refspecs' do + is_expected + .to contain_exactly("+refs/pipelines/#{pipeline.id}:refs/pipelines/#{pipeline.id}", + '+refs/heads/*:refs/remotes/origin/*', + '+refs/tags/*:refs/tags/*') + end + end + + context 'when pipeline is legacy detached merge request pipeline' do + let(:merge_request) { create(:merge_request, :with_legacy_detached_merge_request_pipeline) } + + it 'returns the correct refspecs' do + is_expected.to contain_exactly("+refs/pipelines/#{pipeline.id}:refs/pipelines/#{pipeline.id}", + "+refs/heads/#{build.ref}:refs/remotes/origin/#{build.ref}") + end end end - context 'when pipeline is legacy detached merge request pipeline' do - let(:merge_request) { create(:merge_request, :with_legacy_detached_merge_request_pipeline) } + context 'when depend_on_persistent_pipeline_ref feature flag is disabled' do + before do + stub_feature_flags(depend_on_persistent_pipeline_ref: false) + end it 'returns the correct refspecs' do - is_expected.to contain_exactly("+refs/heads/#{build.ref}:refs/remotes/origin/#{build.ref}") + is_expected + .to contain_exactly('+refs/merge-requests/1/head:refs/merge-requests/1/head') + end + + context 'when GIT_DEPTH is zero' do + before do + create(:ci_pipeline_variable, key: 'GIT_DEPTH', value: 0, pipeline: build.pipeline) + end + + it 'returns the correct refspecs' do + is_expected + .to contain_exactly('+refs/merge-requests/1/head:refs/merge-requests/1/head', + '+refs/heads/*:refs/remotes/origin/*', + '+refs/tags/*:refs/tags/*') + end + end + + context 'when pipeline is legacy detached merge request pipeline' do + let(:merge_request) { create(:merge_request, :with_legacy_detached_merge_request_pipeline) } + + it 'returns the correct refspecs' do + is_expected.to contain_exactly("+refs/heads/#{build.ref}:refs/remotes/origin/#{build.ref}") + end end end end diff --git a/spec/presenters/project_presenter_spec.rb b/spec/presenters/project_presenter_spec.rb index 318024bacd67e9433ca098ddcecb54e47cfc7668..620ef3ff21a558f80f0df8d3dc52b96441b33402 100644 --- a/spec/presenters/project_presenter_spec.rb +++ b/spec/presenters/project_presenter_spec.rb @@ -4,11 +4,10 @@ require 'spec_helper' describe ProjectPresenter do let(:user) { create(:user) } + let(:project) { create(:project) } + let(:presenter) { described_class.new(project, current_user: user) } describe '#license_short_name' do - let(:project) { create(:project) } - let(:presenter) { described_class.new(project, current_user: user) } - context 'when project.repository has a license_key' do it 'returns the nickname of the license if present' do allow(project.repository).to receive(:license_key).and_return('agpl-3.0') @@ -33,13 +32,11 @@ describe ProjectPresenter do end describe '#default_view' do - let(:presenter) { described_class.new(project, current_user: user) } - context 'user not signed in' do - let(:user) { nil } + let_it_be(:user) { nil } context 'when repository is empty' do - let(:project) { create(:project_empty_repo, :public) } + let_it_be(:project) { create(:project_empty_repo, :public) } it 'returns activity if user has repository access' do allow(presenter).to receive(:can?).with(nil, :download_code, project).and_return(true) @@ -55,7 +52,8 @@ describe ProjectPresenter do end context 'when repository is not empty' do - let(:project) { create(:project, :public, :repository) } + let_it_be(:project) { create(:project, :public, :repository) } + let(:release) { create(:release, project: project, author: user) } it 'returns files and readme if user has repository access' do allow(presenter).to receive(:can?).with(nil, :download_code, project).and_return(true) @@ -68,6 +66,15 @@ describe ProjectPresenter do expect(presenter.default_view).to eq('activity') end + + it 'returns releases anchor' do + expect(release).to be_truthy + expect(presenter.releases_anchor_data).to have_attributes( + is_link: true, + label: a_string_including("#{project.releases.count}"), + link: presenter.project_releases_path(project) + ) + end end end @@ -124,11 +131,8 @@ describe ProjectPresenter do end describe '#can_current_user_push_code?' do - let(:project) { create(:project, :repository) } - let(:presenter) { described_class.new(project, current_user: user) } - context 'empty repo' do - let(:project) { create(:project) } + let_it_be(:project) { create(:project) } it 'returns true if user can push_code' do project.add_developer(user) @@ -154,6 +158,7 @@ describe ProjectPresenter do it 'returns false if default branch is protected' do project.add_developer(user) + create(:protected_branch, project: project, name: project.default_branch) expect(presenter.can_current_user_push_code?).to be(false) @@ -162,75 +167,125 @@ describe ProjectPresenter do end context 'statistics anchors (empty repo)' do - let(:project) { create(:project, :empty_repo) } - let(:presenter) { described_class.new(project, current_user: user) } + let_it_be(:project) { create(:project, :empty_repo) } describe '#files_anchor_data' do it 'returns files data' do - expect(presenter.files_anchor_data).to have_attributes(is_link: true, - label: a_string_including('0 Bytes'), - link: nil) + expect(presenter.files_anchor_data).to have_attributes( + is_link: true, + label: a_string_including('0 Bytes'), + link: nil + ) + end + end + + describe '#releases_anchor_data' do + it 'does not return release count' do + expect(presenter.releases_anchor_data).to be_nil end end describe '#commits_anchor_data' do it 'returns commits data' do - expect(presenter.commits_anchor_data).to have_attributes(is_link: true, - label: a_string_including('0'), - link: nil) + 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(is_link: true, - label: a_string_including('0'), - link: nil) + 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(is_link: true, - label: a_string_including('0'), - link: nil) + expect(presenter.tags_anchor_data).to have_attributes( + is_link: true, + label: a_string_including('0'), + link: nil + ) end end end context 'statistics anchors' do - let(:project) { create(:project, :repository) } + let_it_be(:user) { create(:user) } + let_it_be(:project) { create(:project, :repository) } + let_it_be(:release) { create(:release, project: project, author: user) } let(:presenter) { described_class.new(project, current_user: user) } describe '#files_anchor_data' do it 'returns files data' do - expect(presenter.files_anchor_data).to have_attributes(is_link: true, - label: a_string_including('0 Bytes'), - link: presenter.project_tree_path(project)) + 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 '#releases_anchor_data' do + it 'returns release count if user can read release' do + project.add_maintainer(user) + + expect(release).to be_truthy + expect(presenter.releases_anchor_data).to have_attributes( + is_link: true, + label: a_string_including("#{project.releases.count}"), + link: presenter.project_releases_path(project) + ) + end + + it 'returns nil if user cannot read release' do + expect(release).to be_truthy + expect(presenter.releases_anchor_data).to be_nil + end + + context 'user not signed in' do + let_it_be(:user) { nil } + + it 'returns nil if user is signed out' do + expect(release).to be_truthy + expect(presenter.releases_anchor_data).to be_nil + end end end describe '#commits_anchor_data' do it 'returns commits data' do - 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)) + 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(is_link: true, - label: a_string_including("#{project.repository.branches.size}"), - link: presenter.project_branches_path(project)) + 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(is_link: true, - label: a_string_including("#{project.repository.tags.size}"), - link: presenter.project_tags_path(project)) + 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 @@ -238,10 +293,12 @@ 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(is_link: false, - label: a_string_including("New file"), - link: presenter.project_new_blob_path(project, 'master'), - class_modifier: 'success') + 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 it 'returns nil if user cannot push' do @@ -249,7 +306,7 @@ describe ProjectPresenter do end context 'when the project is empty' do - let(:project) { create(:project, :empty_repo) } + let_it_be(:project) { create(:project, :empty_repo) } # Since we protect the default branch for empty repos it 'is empty for a developer' do @@ -264,11 +321,14 @@ describe ProjectPresenter do context 'when user can push and README does not exists' do it 'returns anchor data' do project.add_developer(user) + allow(project.repository).to receive(:readme).and_return(nil) - expect(presenter.readme_anchor_data).to have_attributes(is_link: false, - label: a_string_including('Add README'), - link: presenter.add_readme_path) + expect(presenter.readme_anchor_data).to have_attributes( + is_link: false, + label: a_string_including('Add README'), + link: presenter.add_readme_path + ) end end @@ -276,9 +336,11 @@ 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(is_link: false, - label: a_string_including('README'), - link: presenter.readme_path) + expect(presenter.readme_anchor_data).to have_attributes( + is_link: false, + label: a_string_including('README'), + link: presenter.readme_path + ) end end end @@ -287,11 +349,14 @@ describe ProjectPresenter 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(is_link: false, - label: a_string_including('Add CHANGELOG'), - link: presenter.add_changelog_path) + expect(presenter.changelog_anchor_data).to have_attributes( + is_link: false, + label: a_string_including('Add CHANGELOG'), + link: presenter.add_changelog_path + ) end end @@ -299,9 +364,11 @@ 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(is_link: false, - label: a_string_including('CHANGELOG'), - link: presenter.changelog_path) + expect(presenter.changelog_anchor_data).to have_attributes( + is_link: false, + label: a_string_including('CHANGELOG'), + link: presenter.changelog_path + ) end end end @@ -310,11 +377,14 @@ describe ProjectPresenter 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(is_link: false, - label: a_string_including('Add LICENSE'), - link: presenter.add_license_path) + expect(presenter.license_anchor_data).to have_attributes( + is_link: false, + label: a_string_including('Add LICENSE'), + link: presenter.add_license_path + ) end end @@ -322,9 +392,11 @@ 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(is_link: false, - label: a_string_including(presenter.license_short_name), - link: presenter.license_path) + expect(presenter.license_anchor_data).to have_attributes( + is_link: false, + label: a_string_including(presenter.license_short_name), + link: presenter.license_path + ) end end end @@ -333,11 +405,14 @@ describe ProjectPresenter 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(is_link: false, - label: a_string_including('Add CONTRIBUTING'), - link: presenter.add_contribution_guide_path) + 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 @@ -345,9 +420,11 @@ 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(is_link: false, - label: a_string_including('CONTRIBUTING'), - link: presenter.contribution_guide_path) + expect(presenter.contribution_guide_anchor_data).to have_attributes( + is_link: false, + label: a_string_including('CONTRIBUTING'), + link: presenter.contribution_guide_path + ) end end end @@ -357,21 +434,26 @@ 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(is_link: false, - label: a_string_including('Auto DevOps enabled'), - link: nil) + 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 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(is_link: false, - label: a_string_including('Enable Auto DevOps'), - link: presenter.project_settings_ci_cd_path(project, anchor: 'autodevops-settings')) + 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 end @@ -380,29 +462,37 @@ describe ProjectPresenter do context 'when user can create Kubernetes cluster' do it 'returns link to cluster if only one exists' do project.add_maintainer(user) + cluster = create(:cluster, projects: [project]) - 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)) + 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 it 'returns link to clusters page if more than one exists' do project.add_maintainer(user) + create(:cluster, :production_environment, projects: [project]) create(:cluster, projects: [project]) - expect(presenter.kubernetes_cluster_anchor_data).to have_attributes(is_link: false, - label: a_string_including('Kubernetes configured'), - link: presenter.project_clusters_path(project)) + 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(is_link: false, - label: a_string_including('Add Kubernetes cluster'), - link: presenter.new_project_cluster_path(project)) + 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 @@ -416,7 +506,6 @@ describe ProjectPresenter do describe '#statistics_buttons' do let(:project) { build(:project) } - let(:presenter) { described_class.new(project, current_user: user) } it 'orders the items correctly' do allow(project.repository).to receive(:readme).and_return(double(name: 'readme')) @@ -435,8 +524,6 @@ describe ProjectPresenter do end describe '#repo_statistics_buttons' do - let(:presenter) { described_class.new(project, current_user: user) } - subject(:empty_repo_statistics_buttons) { presenter.empty_repo_statistics_buttons } before do @@ -473,7 +560,7 @@ describe ProjectPresenter do end context 'initialized repo' do - let(:project) { create(:project, :repository) } + let_it_be(:project) { create(:project, :repository) } it 'orders the items correctly' do expect(empty_repo_statistics_buttons.map(&:label)).to start_with( @@ -485,4 +572,73 @@ describe ProjectPresenter do end end end + + describe '#can_setup_review_app?' do + subject { presenter.can_setup_review_app? } + + context 'when the ci/cd file is missing' do + before do + allow(presenter).to receive(:cicd_missing?).and_return(true) + end + + it { is_expected.to be_truthy } + end + + context 'when the ci/cd file is not missing' do + before do + allow(presenter).to receive(:cicd_missing?).and_return(false) + end + + context 'and the user can create a cluster' do + before do + allow(Ability).to receive(:allowed?).and_call_original + allow(Ability).to receive(:allowed?).with(user, :create_cluster, project).and_return(true) + end + + context 'and there is no cluster associated to this project' do + let(:project) { create(:project, clusters: []) } + + it { is_expected.to be_truthy } + end + + context 'and there is already a cluster associated to this project' do + let(:project) { create(:project, clusters: [build(:cluster, :providing_by_gcp)]) } + + it { is_expected.to be_falsey } + end + + context 'when a group cluster is instantiated' do + let_it_be(:cluster) { create(:cluster, :group) } + let_it_be(:group) { cluster.group } + + context 'and the project belongs to this group' do + let!(:project) { create(:project, group: group) } + + it { is_expected.to be_falsey } + end + + context 'and the project does not belong to this group' do + it { is_expected.to be_truthy } + end + end + + context 'and there is already an instance cluster' do + it 'is false' do + create(:cluster, :instance) + + is_expected.to be_falsey + end + end + end + + context 'and the user cannot create a cluster' do + before do + allow(Ability).to receive(:allowed?).and_call_original + allow(Ability).to receive(:allowed?).with(user, :create_cluster, project).and_return(false) + end + + it { is_expected.to be_falsey } + end + end + end end diff --git a/spec/requests/api/appearance_spec.rb b/spec/requests/api/appearance_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..40fd216f32dd3f08f8c62736772789091f745f0f --- /dev/null +++ b/spec/requests/api/appearance_spec.rb @@ -0,0 +1,142 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe API::Appearance, 'Appearance' do + let_it_be(:user) { create(:user) } + let_it_be(:admin) { create(:admin) } + + describe "GET /application/appearance" do + context 'as a non-admin user' do + it "returns 403" do + get api("/application/appearance", user) + + expect(response).to have_gitlab_http_status(403) + end + end + + context 'as an admin user' do + it "returns appearance" do + get api("/application/appearance", admin) + + expect(response).to have_gitlab_http_status(200) + expect(json_response).to be_an Hash + expect(json_response['description']).to eq('') + expect(json_response['email_header_and_footer_enabled']).to be(false) + expect(json_response['favicon']).to be_nil + expect(json_response['footer_message']).to eq('') + expect(json_response['header_logo']).to be_nil + expect(json_response['header_message']).to eq('') + expect(json_response['logo']).to be_nil + expect(json_response['message_background_color']).to eq('#E75E40') + expect(json_response['message_font_color']).to eq('#FFFFFF') + expect(json_response['new_project_guidelines']).to eq('') + expect(json_response['title']).to eq('') + end + end + end + + describe "PUT /application/appearance" do + context 'as a non-admin user' do + it "returns 403" do + put api("/application/appearance", user), params: { title: "Test" } + + expect(response).to have_gitlab_http_status(403) + end + end + + context 'as an admin user' do + context "instance basics" do + it "allows updating the settings" do + put api("/application/appearance", admin), params: { + title: "GitLab Test Instance", + description: "gitlab-test.example.com", + new_project_guidelines: "Please read the FAQs for help." + } + + expect(response).to have_gitlab_http_status(200) + expect(json_response).to be_an Hash + expect(json_response['description']).to eq('gitlab-test.example.com') + expect(json_response['email_header_and_footer_enabled']).to be(false) + expect(json_response['favicon']).to be_nil + expect(json_response['footer_message']).to eq('') + expect(json_response['header_logo']).to be_nil + expect(json_response['header_message']).to eq('') + expect(json_response['logo']).to be_nil + expect(json_response['message_background_color']).to eq('#E75E40') + expect(json_response['message_font_color']).to eq('#FFFFFF') + expect(json_response['new_project_guidelines']).to eq('Please read the FAQs for help.') + expect(json_response['title']).to eq('GitLab Test Instance') + end + end + + context "system header and footer" do + it "allows updating the settings" do + settings = { + footer_message: "This is a Header", + header_message: "This is a Footer", + message_font_color: "#ffffff", + message_background_color: "#009999", + email_header_and_footer_enabled: true + } + + put api("/application/appearance", admin), params: settings + + expect(response).to have_gitlab_http_status(200) + settings.each do |attribute, value| + expect(Appearance.current.public_send(attribute)).to eq(value) + end + end + + context "fails on invalid color values" do + it "with message_font_color" do + put api("/application/appearance", admin), params: { message_font_color: "No Color" } + + expect(response).to have_gitlab_http_status(400) + expect(json_response['message']['message_font_color']).to contain_exactly('must be a valid color code') + end + + it "with message_background_color" do + put api("/application/appearance", admin), params: { message_background_color: "#1" } + + expect(response).to have_gitlab_http_status(400) + expect(json_response['message']['message_background_color']).to contain_exactly('must be a valid color code') + end + end + end + + context "instance logos" do + let_it_be(:appearance) { create(:appearance) } + + it "allows updating the image files" do + put api("/application/appearance", admin), params: { + logo: fixture_file_upload("spec/fixtures/dk.png", "image/png"), + header_logo: fixture_file_upload("spec/fixtures/dk.png", "image/png"), + favicon: fixture_file_upload("spec/fixtures/dk.png", "image/png") + } + + expect(response).to have_gitlab_http_status(200) + expect(json_response['logo']).to eq("/uploads/-/system/appearance/logo/#{appearance.id}/dk.png") + expect(json_response['header_logo']).to eq("/uploads/-/system/appearance/header_logo/#{appearance.id}/dk.png") + expect(json_response['favicon']).to eq("/uploads/-/system/appearance/favicon/#{appearance.id}/dk.png") + end + + context "fails on invalid color images" do + it "with string instead of file" do + put api("/application/appearance", admin), params: { logo: 'not-a-file.png' } + + expect(response).to have_gitlab_http_status(400) + expect(json_response['error']).to eq("logo is invalid") + end + + it "with .svg file instead of .png" do + put api("/application/appearance", admin), params: { favicon: fixture_file_upload("spec/fixtures/logo_sample.svg", "image/svg") } + + expect(response).to have_gitlab_http_status(400) + expect(json_response['message']['favicon']).to contain_exactly("You are not allowed to upload \"svg\" files, allowed types: png, ico") + end + end + end + end + end +end diff --git a/spec/requests/api/deployments_spec.rb b/spec/requests/api/deployments_spec.rb index 3dc8e5749d44545cffb20a396df830416877a456..d8fc234cbaeb4d23cf4fb795ee5762c3fbf410b4 100644 --- a/spec/requests/api/deployments_spec.rb +++ b/spec/requests/api/deployments_spec.rb @@ -11,10 +11,10 @@ describe API::Deployments do end describe 'GET /projects/:id/deployments' do - let(:project) { create(:project) } + let(:project) { create(:project, :repository) } let!(:deployment_1) { create(:deployment, :success, project: project, iid: 11, ref: 'master', created_at: Time.now, updated_at: Time.now) } - let!(:deployment_2) { create(:deployment, :success, project: project, iid: 12, ref: 'feature', created_at: 1.day.ago, updated_at: 2.hours.ago) } - let!(:deployment_3) { create(:deployment, :success, project: project, iid: 8, ref: 'patch', created_at: 2.days.ago, updated_at: 1.hour.ago) } + let!(:deployment_2) { create(:deployment, :success, project: project, iid: 12, ref: 'master', created_at: 1.day.ago, updated_at: 2.hours.ago) } + let!(:deployment_3) { create(:deployment, :success, project: project, iid: 8, ref: 'master', created_at: 2.days.ago, updated_at: 1.hour.ago) } context 'as member of the project' do it 'returns projects deployments sorted by id asc' do @@ -40,6 +40,18 @@ describe API::Deployments do end end + context 'with the environment filter specifed' do + it 'returns deployments for the environment' do + get( + api("/projects/#{project.id}/deployments", user), + params: { environment: deployment_1.environment.name } + ) + + expect(json_response.size).to eq(1) + expect(json_response.first['iid']).to eq(deployment_1.iid) + end + end + describe 'ordering' do let(:order_by) { 'iid' } let(:sort) { 'desc' } @@ -343,38 +355,70 @@ describe API::Deployments do end end - context 'prevent N + 1 queries' do - context 'when the endpoint returns multiple records' do - let(:project) { create(:project) } + describe 'GET /projects/:id/deployments/:deployment_id/merge_requests' do + let(:project) { create(:project, :repository) } + let!(:deployment) { create(:deployment, :success, project: project) } - def create_record - create(:deployment, :success, project: project) - end + subject { get api("/projects/#{project.id}/deployments/#{deployment.id}/merge_requests", user) } + + context 'when a user is not a member of the deployment project' do + let(:user) { build(:user) } + + it 'returns a 404 status code' do + subject - def request_with_query_count - ActiveRecord::QueryRecorder.new { trigger_request }.count + expect(response).to have_gitlab_http_status(404) end + end + + context 'when a user member of the deployment project' do + let_it_be(:project2) { create(:project) } + let!(:merge_request1) { create(:merge_request, source_project: project, target_project: project) } + let!(:merge_request2) { create(:merge_request, source_project: project, target_project: project, state: 'closed') } + let!(:merge_request3) { create(:merge_request, source_project: project2, target_project: project2) } + + it 'returns the relevant merge requests linked to a deployment for a project' do + deployment.merge_requests << [merge_request1, merge_request2] - def trigger_request - get api("/projects/#{project.id}/deployments?order_by=updated_at&sort=asc", user) + subject + + expect(response).to have_gitlab_http_status(200) + expect(json_response.map { |d| d['id'] }).to contain_exactly(merge_request1.id, merge_request2.id) end - before do - create_record + context 'when a deployment is not associated to any existing merge requests' do + it 'returns an empty array' do + subject + + expect(response).to have_gitlab_http_status(200) + expect(json_response).to eq([]) + end end + end + end - it 'succeeds' do - trigger_request + context 'prevent N + 1 queries' do + context 'when the endpoint returns multiple records' do + let(:project) { create(:project, :repository) } + let!(:deployment) { create(:deployment, :success, project: project) } - expect(response).to have_gitlab_http_status(200) + subject { get api("/projects/#{project.id}/deployments?order_by=updated_at&sort=asc", user) } + + it 'succeeds', :aggregate_failures do + subject + expect(response).to have_gitlab_http_status(200) expect(json_response.size).to eq(1) end - it 'does not increase the query count' do - expect { create_record }.not_to change { request_with_query_count } + context 'with 10 more records' do + it 'does not increase the query count', :aggregate_failures do + create_list(:deployment, 10, :success, project: project) + + expect { subject }.not_to be_n_plus_1_query - expect(json_response.size).to eq(2) + expect(json_response.size).to eq(11) + end end end end diff --git a/spec/requests/api/discussions_spec.rb b/spec/requests/api/discussions_spec.rb index 68f7d407b5487b4a6976ed3b5d53f32ef67e5f45..f37a02e71352da44831fef04de688328a8cfbf3c 100644 --- a/spec/requests/api/discussions_spec.rb +++ b/spec/requests/api/discussions_spec.rb @@ -49,6 +49,18 @@ describe API::Discussions do it_behaves_like 'discussions API', 'projects', 'merge_requests', 'iid', can_reply_to_individual_notes: true it_behaves_like 'diff discussions API', 'projects', 'merge_requests', 'iid' it_behaves_like 'resolvable discussions API', 'projects', 'merge_requests', 'iid' + + context "when position is for a previous commit on the merge request" do + it "returns a 400 bad request error because the line_code is old" do + # SHA taken from an earlier commit listed in spec/factories/merge_requests.rb + position = diff_note.position.to_h.merge(new_line: 'c1acaa58bbcbc3eafe538cb8274ba387047b69f8') + + post api("/projects/#{project.id}/merge_requests/#{noteable['iid']}/discussions", user), + params: { body: 'hi!', position: position } + + expect(response).to have_gitlab_http_status(400) + end + end end context 'when noteable is a Commit' do diff --git a/spec/requests/api/environments_spec.rb b/spec/requests/api/environments_spec.rb index aa273e97209b211359a3973fd433c537a761accb..bdb0ef440388610a7934511007972e994c21e6b5 100644 --- a/spec/requests/api/environments_spec.rb +++ b/spec/requests/api/environments_spec.rb @@ -5,7 +5,7 @@ require 'spec_helper' describe API::Environments do let(:user) { create(:user) } let(:non_member) { create(:user) } - let(:project) { create(:project, :private, namespace: user.namespace) } + let(:project) { create(:project, :private, :repository, namespace: user.namespace) } let!(:environment) { create(:environment, project: project) } before do diff --git a/spec/requests/api/error_tracking_spec.rb b/spec/requests/api/error_tracking_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..48ddc7f5a75c5f14a15ed75b801e8c93d3cff021 --- /dev/null +++ b/spec/requests/api/error_tracking_spec.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe API::ErrorTracking do + describe "GET /projects/:id/error_tracking/settings" do + let(:user) { create(:user) } + let(:setting) { create(:project_error_tracking_setting) } + let(:project) { setting.project } + + def make_request + get api("/projects/#{project.id}/error_tracking/settings", user) + end + + context 'when authenticated as maintainer' do + before do + project.add_maintainer(user) + end + + it 'returns project settings' do + make_request + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response).to eq( + 'active' => setting.enabled, + 'project_name' => setting.project_name, + 'sentry_external_url' => setting.sentry_external_url, + 'api_url' => setting.api_url + ) + end + end + + context 'without a project setting' do + let(:project) { create(:project) } + + before do + project.add_maintainer(user) + end + + it 'returns 404' do + make_request + + expect(response).to have_gitlab_http_status(:not_found) + expect(json_response['message']) + .to eq('404 Error Tracking Setting Not Found') + end + end + + context 'when authenticated as reporter' do + before do + project.add_reporter(user) + end + + it 'returns 403' do + make_request + + expect(response).to have_gitlab_http_status(:forbidden) + end + end + + context 'when authenticated as non-member' do + it 'returns 404' do + make_request + + expect(response).to have_gitlab_http_status(:not_found) + end + end + + context 'when unauthenticated' do + let(:user) { nil } + + it 'returns 401' do + make_request + + expect(response).to have_gitlab_http_status(:unauthorized) + end + end + end +end diff --git a/spec/requests/api/events_spec.rb b/spec/requests/api/events_spec.rb index 9f8d254a00ca75db5bcf5a2a6b52f3a488b0b071..240f9a02877efe66c91c7ba8bbd79c225422df2c 100644 --- a/spec/requests/api/events_spec.rb +++ b/spec/requests/api/events_spec.rb @@ -8,6 +8,8 @@ describe API::Events do let(:private_project) { create(:project, :private, creator_id: user.id, namespace: user.namespace) } let(:closed_issue) { create(:closed_issue, project: private_project, author: user) } let!(:closed_issue_event) { create(:event, project: private_project, author: user, target: closed_issue, action: Event::CLOSED, created_at: Date.new(2016, 12, 30)) } + let(:closed_issue2) { create(:closed_issue, project: private_project, author: non_member) } + let!(:closed_issue_event2) { create(:event, project: private_project, author: non_member, target: closed_issue2, action: Event::CLOSED, created_at: Date.new(2016, 12, 30)) } describe 'GET /events' do context 'when unauthenticated' do @@ -27,6 +29,19 @@ describe API::Events do expect(json_response).to be_an Array expect(json_response.size).to eq(1) end + + context 'when scope is passed' do + it 'returns all events across projects' do + private_project.add_developer(non_member) + + get api('/events?action=closed&target_type=issue&after=2016-12-1&before=2016-12-31&scope=all', user) + + expect(response).to have_gitlab_http_status(200) + expect(response).to include_pagination_headers + expect(json_response).to be_an Array + expect(json_response.size).to eq(2) + end + end end context 'when the requesting token has "read_user" scope' do diff --git a/spec/requests/api/graphql/mutations/snippets/mark_as_spam_spec.rb b/spec/requests/api/graphql/mutations/snippets/mark_as_spam_spec.rb index 0e8fe4987b9bf21057939b1d3f62b24d3c7214bd..f80a3401134d5ae2fafa4946c0210a576ca662e1 100644 --- a/spec/requests/api/graphql/mutations/snippets/mark_as_spam_spec.rb +++ b/spec/requests/api/graphql/mutations/snippets/mark_as_spam_spec.rb @@ -52,8 +52,8 @@ describe 'Mark snippet as spam' do end it 'marks snippet as spam' do - expect_next_instance_of(SpamService) do |instance| - expect(instance).to receive(:mark_as_spam!) + expect_next_instance_of(Spam::MarkAsSpamService) do |instance| + expect(instance).to receive(:execute) end post_graphql_mutation(mutation, current_user: current_user) diff --git a/spec/requests/api/graphql/project/error_tracking/sentry_detailed_error_request_spec.rb b/spec/requests/api/graphql/project/error_tracking/sentry_detailed_error_request_spec.rb index d10380dab3a14e75a6e78cc715558c2d80b486cf..664206dec2961c38f17d6dffa404205aab65eea9 100644 --- a/spec/requests/api/graphql/project/error_tracking/sentry_detailed_error_request_spec.rb +++ b/spec/requests/api/graphql/project/error_tracking/sentry_detailed_error_request_spec.rb @@ -56,6 +56,7 @@ describe 'getting a detailed sentry error' do expect(error_data['status']).to eql sentry_detailed_error.status.upcase expect(error_data['firstSeen']).to eql sentry_detailed_error.first_seen expect(error_data['lastSeen']).to eql sentry_detailed_error.last_seen + expect(error_data['gitlabCommit']).to be nil end it 'is expected to return the frequency correctly' do diff --git a/spec/requests/api/graphql/project/grafana_integration_spec.rb b/spec/requests/api/graphql/project/grafana_integration_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..6075efb0cbdbc2707fe40d8d6b6d5c8500c33c6d --- /dev/null +++ b/spec/requests/api/graphql/project/grafana_integration_spec.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true +require 'spec_helper' + +describe 'Getting Grafana Integration' do + include GraphqlHelpers + + let_it_be(:project) { create(:project, :repository) } + let_it_be(:current_user) { project.owner } + let_it_be(:grafana_integration) { create(:grafana_integration, project: project) } + + let(:fields) do + <<~QUERY + #{all_graphql_fields_for('GrafanaIntegration'.classify)} + QUERY + end + + let(:query) do + graphql_query_for( + 'project', + { 'fullPath' => project.full_path }, + query_graphql_field('grafanaIntegration', {}, fields) + ) + end + + context 'with grafana integration data' do + let(:integration_data) { graphql_data['project']['grafanaIntegration'] } + + context 'without project admin permissions' do + let(:user) { create(:user) } + + before do + project.add_developer(user) + post_graphql(query, current_user: user) + end + + it_behaves_like 'a working graphql query' + + it { expect(integration_data).to be nil } + end + + context 'with project admin permissions' do + before do + post_graphql(query, current_user: current_user) + end + + it_behaves_like 'a working graphql query' + + it { expect(integration_data['token']).to eql grafana_integration.token } + it { expect(integration_data['grafanaUrl']).to eql grafana_integration.grafana_url } + + it do + expect( + integration_data['createdAt'] + ).to eql grafana_integration.created_at.strftime('%Y-%m-%dT%H:%M:%SZ') + end + + it do + expect( + integration_data['updatedAt'] + ).to eql grafana_integration.updated_at.strftime('%Y-%m-%dT%H:%M:%SZ') + end + end + end +end diff --git a/spec/requests/api/groups_spec.rb b/spec/requests/api/groups_spec.rb index a4f68df928f110f05551b95853ade0de5a4001f2..35b77832c73d8ba8f793a2df16f89c1d37ccf610 100644 --- a/spec/requests/api/groups_spec.rb +++ b/spec/requests/api/groups_spec.rb @@ -358,6 +358,7 @@ describe API::Groups do expect(json_response['two_factor_grace_period']).to eq(group1.two_factor_grace_period) expect(json_response['auto_devops_enabled']).to eq(group1.auto_devops_enabled) expect(json_response['emails_disabled']).to eq(group1.emails_disabled) + expect(json_response['mentions_disabled']).to eq(group1.mentions_disabled) expect(json_response['project_creation_level']).to eq('maintainer') expect(json_response['subgroup_creation_level']).to eq('maintainer') expect(json_response['web_url']).to eq(group1.web_url) @@ -556,6 +557,7 @@ describe API::Groups do expect(json_response['two_factor_grace_period']).to eq(48) expect(json_response['auto_devops_enabled']).to eq(nil) expect(json_response['emails_disabled']).to eq(nil) + expect(json_response['mentions_disabled']).to eq(nil) expect(json_response['project_creation_level']).to eq("noone") expect(json_response['subgroup_creation_level']).to eq("maintainer") expect(json_response['request_access_enabled']).to eq(true) diff --git a/spec/requests/api/internal/base_spec.rb b/spec/requests/api/internal/base_spec.rb index ecbb81294a0c193fdec671de524a1a67f2fd5bfb..12e6e7c7a093bd37197448fc4fa897a1168e26a7 100644 --- a/spec/requests/api/internal/base_spec.rb +++ b/spec/requests/api/internal/base_spec.rb @@ -326,7 +326,7 @@ describe API::Internal::Base do expect(json_response["gitaly"]["repository"]["relative_path"]).to eq(project.repository.gitaly_repository.relative_path) expect(json_response["gitaly"]["address"]).to eq(Gitlab::GitalyClient.address(project.repository_storage)) expect(json_response["gitaly"]["token"]).to eq(Gitlab::GitalyClient.token(project.repository_storage)) - expect(json_response["gitaly"]["features"]).to eq('gitaly-feature-inforef-uploadpack-cache' => 'true', 'gitaly-feature-get-tag-messages-go' => 'true', 'gitaly-feature-filter-shas-with-signatures-go' => 'true') + expect(json_response["gitaly"]["features"]).to eq('gitaly-feature-inforef-uploadpack-cache' => 'true', 'gitaly-feature-get-tag-messages-go' => 'true', 'gitaly-feature-filter-shas-with-signatures-go' => 'true', 'gitaly-feature-cache-invalidator' => 'true') expect(user.reload.last_activity_on).to eql(Date.today) end end @@ -346,7 +346,7 @@ describe API::Internal::Base do expect(json_response["gitaly"]["repository"]["relative_path"]).to eq(project.repository.gitaly_repository.relative_path) expect(json_response["gitaly"]["address"]).to eq(Gitlab::GitalyClient.address(project.repository_storage)) expect(json_response["gitaly"]["token"]).to eq(Gitlab::GitalyClient.token(project.repository_storage)) - expect(json_response["gitaly"]["features"]).to eq('gitaly-feature-inforef-uploadpack-cache' => 'true', 'gitaly-feature-get-tag-messages-go' => 'true', 'gitaly-feature-filter-shas-with-signatures-go' => 'true') + expect(json_response["gitaly"]["features"]).to eq('gitaly-feature-inforef-uploadpack-cache' => 'true', 'gitaly-feature-get-tag-messages-go' => 'true', 'gitaly-feature-filter-shas-with-signatures-go' => 'true', 'gitaly-feature-cache-invalidator' => 'true') expect(user.reload.last_activity_on).to be_nil end end @@ -389,6 +389,12 @@ describe API::Internal::Base do end end end + + it_behaves_like 'storing arguments in the application context' do + let(:expected_params) { { user: key.user.username, project: project.full_path } } + + subject { push(key, project) } + end end context "access denied" do @@ -588,7 +594,7 @@ describe API::Internal::Base do expect(json_response["gitaly"]["repository"]["relative_path"]).to eq(project.repository.gitaly_repository.relative_path) expect(json_response["gitaly"]["address"]).to eq(Gitlab::GitalyClient.address(project.repository_storage)) expect(json_response["gitaly"]["token"]).to eq(Gitlab::GitalyClient.token(project.repository_storage)) - expect(json_response["gitaly"]["features"]).to eq('gitaly-feature-inforef-uploadpack-cache' => 'true', 'gitaly-feature-get-tag-messages-go' => 'true', 'gitaly-feature-filter-shas-with-signatures-go' => 'true') + expect(json_response["gitaly"]["features"]).to eq('gitaly-feature-inforef-uploadpack-cache' => 'true', 'gitaly-feature-get-tag-messages-go' => 'true', 'gitaly-feature-filter-shas-with-signatures-go' => 'true', 'gitaly-feature-cache-invalidator' => 'true') end end @@ -885,6 +891,12 @@ describe API::Internal::Base do post api('/internal/post_receive'), params: valid_params end + it_behaves_like 'storing arguments in the application context' do + let(:expected_params) { { user: user.username, project: project.full_path } } + + subject { post api('/internal/post_receive'), params: valid_params } + end + context 'when there are merge_request push options' do before do valid_params[:push_options] = ['merge_request.create'] @@ -1000,6 +1012,22 @@ describe API::Internal::Base do it 'does not try to notify that project moved' do allow_any_instance_of(Gitlab::Identifier).to receive(:identify).and_return(nil) + expect(Gitlab::Checks::ProjectMoved).not_to receive(:fetch_message) + + post api('/internal/post_receive'), params: valid_params + + expect(response).to have_gitlab_http_status(200) + end + end + + context 'when project is nil' do + let(:gl_repository) { 'project-foo' } + + it 'does not try to notify that project moved' do + allow(Gitlab::GlRepository).to receive(:parse).and_return([nil, Gitlab::GlRepository::PROJECT]) + + expect(Gitlab::Checks::ProjectMoved).not_to receive(:fetch_message) + post api('/internal/post_receive'), params: valid_params expect(response).to have_gitlab_http_status(200) diff --git a/spec/requests/api/issues/get_group_issues_spec.rb b/spec/requests/api/issues/get_group_issues_spec.rb index 3ee08758f99455865b2e2f735a4c24824cd2f8ec..ef63902ffd74e543b2b78ff62f5e87d49b111bc6 100644 --- a/spec/requests/api/issues/get_group_issues_spec.rb +++ b/spec/requests/api/issues/get_group_issues_spec.rb @@ -688,5 +688,32 @@ describe API::Issues do end end end + + context "#to_reference" do + it 'exposes reference path in context of group' do + get api(base_url, user) + + expect(json_response.first['references']['short']).to eq("##{group_closed_issue.iid}") + expect(json_response.first['references']['relative']).to eq("#{group_closed_issue.project.path}##{group_closed_issue.iid}") + expect(json_response.first['references']['full']).to eq("#{group_closed_issue.project.full_path}##{group_closed_issue.iid}") + end + + context 'referencing from parent group' do + let(:parent_group) { create(:group) } + + before do + group.update(parent_id: parent_group.id) + group_closed_issue.reload + end + + it 'exposes reference path in context of parent group' do + get api("/groups/#{parent_group.id}/issues") + + expect(json_response.first['references']['short']).to eq("##{group_closed_issue.iid}") + expect(json_response.first['references']['relative']).to eq("#{group_closed_issue.project.full_path}##{group_closed_issue.iid}") + expect(json_response.first['references']['full']).to eq("#{group_closed_issue.project.full_path}##{group_closed_issue.iid}") + end + end + end end end diff --git a/spec/requests/api/issues/get_project_issues_spec.rb b/spec/requests/api/issues/get_project_issues_spec.rb index 59aeb91edd2fc66bf662ce9c7d15d35a389b382e..e031cc9b0c6d1fd6d3898b20f448457ac0323cf6 100644 --- a/spec/requests/api/issues/get_project_issues_spec.rb +++ b/spec/requests/api/issues/get_project_issues_spec.rb @@ -299,6 +299,26 @@ describe API::Issues do it_behaves_like 'labeled issues with labels and label_name params' end + context 'with_labels_details' do + let(:label_b) { create(:label, title: 'foo', project: project) } + let(:label_c) { create(:label, title: 'bar', project: project) } + + it 'avoids N+1 queries' do + control_count = ActiveRecord::QueryRecorder.new(skip_cached: false) do + get api("/projects/#{project.id}/issues?with_labels_details=true", user) + end.count + + new_issue = create(:issue, project: project) + create(:label_link, label: label, target: new_issue) + create(:label_link, label: label_b, target: new_issue) + create(:label_link, label: label_c, target: new_issue) + + expect do + get api("/projects/#{project.id}/issues?with_labels_details=true", user) + end.not_to exceed_all_query_limit(control_count) + end + end + it 'returns issues matching given search string for title' do get api("#{base_url}/issues?search=#{issue.title}", user) diff --git a/spec/requests/api/issues/issues_spec.rb b/spec/requests/api/issues/issues_spec.rb index 50a0a80b542fa04ac0a02a4fe3f11addf0ff8123..a3538aa98b1be61e70f87d0cc8a509ead4c05279 100644 --- a/spec/requests/api/issues/issues_spec.rb +++ b/spec/requests/api/issues/issues_spec.rb @@ -805,6 +805,17 @@ describe API::Issues do end end + describe 'GET /projects/:id/issues/:issue_iid' do + it 'exposes full reference path' do + get api("/projects/#{project.id}/issues/#{issue.iid}", user) + + expect(response).to have_gitlab_http_status(200) + expect(json_response['references']['short']).to eq("##{issue.iid}") + expect(json_response['references']['relative']).to eq("##{issue.iid}") + expect(json_response['references']['full']).to eq("#{project.parent.path}/#{project.path}##{issue.iid}") + end + end + describe 'DELETE /projects/:id/issues/:issue_iid' do it 'rejects a non member from deleting an issue' do delete api("/projects/#{project.id}/issues/#{issue.iid}", non_member) diff --git a/spec/requests/api/issues/post_projects_issues_spec.rb b/spec/requests/api/issues/post_projects_issues_spec.rb index e9f678d164ea2395a765c91f1efc6d1f99174774..67404cf10dff67c7392ceb35401a4db1a0258c5e 100644 --- a/spec/requests/api/issues/post_projects_issues_spec.rb +++ b/spec/requests/api/issues/post_projects_issues_spec.rb @@ -160,6 +160,16 @@ describe API::Issues do expect(json_response['iid']).not_to eq 9001 end end + + context 'when an issue with the same IID exists on database' do + it 'returns 409' do + post api("/projects/#{project.id}/issues", admin), + params: { title: 'new issue', iid: issue.iid } + + expect(response).to have_gitlab_http_status(409) + expect(json_response['message']).to eq 'Duplicated issue' + end + end end it 'creates a new project issue' do diff --git a/spec/requests/api/jobs_spec.rb b/spec/requests/api/jobs_spec.rb index 82bf607b9117e2a62bc0f9299c4f113bc4994c56..1e1099ebcb681e62b8cba5c2ba974fed4bd83f18 100644 --- a/spec/requests/api/jobs_spec.rb +++ b/spec/requests/api/jobs_spec.rb @@ -244,7 +244,7 @@ describe API::Jobs do get api("/projects/#{project.id}/pipelines/#{pipeline.id}/jobs", api_user), params: query end.count - 3.times { create(:ci_build, :trace_artifact, :artifacts, :test_reports, pipeline: pipeline) } + create_list(:ci_build, 3, :trace_artifact, :artifacts, :test_reports, pipeline: pipeline) expect do get api("/projects/#{project.id}/pipelines/#{pipeline.id}/jobs", api_user), params: query diff --git a/spec/requests/api/keys_spec.rb b/spec/requests/api/keys_spec.rb index f7da1abcfdf1aaafbc7d64e4e818f7e0fb60d5b3..c743cb3f633e155b7bb4543dd8fbe56685ec07cb 100644 --- a/spec/requests/api/keys_spec.rb +++ b/spec/requests/api/keys_spec.rb @@ -106,6 +106,36 @@ describe API::Keys do expect(json_response['user']['is_admin']).to be_nil end + + context 'when searching a DeployKey' do + let(:project) { create(:project, :repository) } + let(:project_push) { create(:project, :repository) } + let(:deploy_key) { create(:deploy_key) } + + let!(:deploy_keys_project) do + create(:deploy_keys_project, project: project, deploy_key: deploy_key) + end + + let!(:deploy_keys_project_push) do + create(:deploy_keys_project, project: project_push, deploy_key: deploy_key, can_push: true) + end + + it 'returns user and projects if SSH sha256 fingerprint for DeployKey found' do + user.keys << deploy_key + + get api("/keys?fingerprint=#{URI.encode_www_form_component("SHA256:" + deploy_key.fingerprint_sha256)}", admin) + + expect(response).to have_gitlab_http_status(200) + expect(json_response['title']).to eq(deploy_key.title) + expect(json_response['user']['id']).to eq(user.id) + + expect(json_response['deploy_keys_projects'].count).to eq(2) + expect(json_response['deploy_keys_projects'][0]['project_id']).to eq(deploy_keys_project.project.id) + expect(json_response['deploy_keys_projects'][0]['can_push']).to eq(deploy_keys_project.can_push) + expect(json_response['deploy_keys_projects'][1]['project_id']).to eq(deploy_keys_project_push.project.id) + expect(json_response['deploy_keys_projects'][1]['can_push']).to eq(deploy_keys_project_push.can_push) + end + end end end end diff --git a/spec/requests/api/merge_requests_spec.rb b/spec/requests/api/merge_requests_spec.rb index e5ad1a6378e1125be117b2016e6cadaf29f797e9..ae0596bea98c13468faefc99d08371c7a3b1115a 100644 --- a/spec/requests/api/merge_requests_spec.rb +++ b/spec/requests/api/merge_requests_spec.rb @@ -88,6 +88,34 @@ describe API::MergeRequests do expect(json_response.first['merge_commit_sha']).not_to be_nil expect(json_response.first['merge_commit_sha']).to eq(merge_request_merged.merge_commit_sha) end + + context 'with labels_details' do + it 'returns labels with details' do + path = endpoint_path + "?with_labels_details=true" + + get api(path, user) + + expect(response).to have_gitlab_http_status(200) + expect(json_response.last['labels'].pluck('name')).to eq([label2.title, label.title]) + expect(json_response.last['labels'].first).to match_schema('/public_api/v4/label_basic') + end + + it 'avoids N+1 queries' do + path = endpoint_path + "?with_labels_details=true" + + control = ActiveRecord::QueryRecorder.new do + get api(path, user) + end.count + + mr = create(:merge_request) + create(:label_link, label: label, target: mr) + create(:label_link, label: label2, target: mr) + + expect do + get api(path, user) + end.not_to exceed_query_limit(control) + end + end end it 'returns an array of all merge_requests using simple mode' do @@ -736,6 +764,33 @@ describe API::MergeRequests do it_behaves_like 'merge requests list' end + + context "#to_reference" do + it 'exposes reference path in context of group' do + get api("/groups/#{group.id}/merge_requests", user) + + expect(json_response.first['references']['short']).to eq("!#{merge_request_merged.iid}") + expect(json_response.first['references']['relative']).to eq("#{merge_request_merged.target_project.path}!#{merge_request_merged.iid}") + expect(json_response.first['references']['full']).to eq("#{merge_request_merged.target_project.full_path}!#{merge_request_merged.iid}") + end + + context 'referencing from parent group' do + let(:parent_group) { create(:group) } + + before do + group.update(parent_id: parent_group.id) + merge_request_merged.reload + end + + it 'exposes reference path in context of parent group' do + get api("/groups/#{parent_group.id}/merge_requests") + + expect(json_response.first['references']['short']).to eq("!#{merge_request_merged.iid}") + expect(json_response.first['references']['relative']).to eq("#{merge_request_merged.target_project.full_path}!#{merge_request_merged.iid}") + expect(json_response.first['references']['full']).to eq("#{merge_request_merged.target_project.full_path}!#{merge_request_merged.iid}") + end + end + end end describe "GET /projects/:id/merge_requests/:merge_request_iid" do @@ -783,6 +838,9 @@ describe API::MergeRequests do expect(json_response).not_to include('rebase_in_progress') expect(json_response['has_conflicts']).to be_falsy expect(json_response['blocking_discussions_resolved']).to be_truthy + expect(json_response['references']['short']).to eq("!#{merge_request.iid}") + expect(json_response['references']['relative']).to eq("!#{merge_request.iid}") + expect(json_response['references']['full']).to eq("#{merge_request.target_project.full_path}!#{merge_request.iid}") end it 'exposes description and title html when render_html is true' do @@ -1491,7 +1549,7 @@ describe API::MergeRequests do end end - describe "PUT /projects/:id/merge_requests/:merge_request_iid/merge" do + describe "PUT /projects/:id/merge_requests/:merge_request_iid/merge", :clean_gitlab_redis_cache do let(:pipeline) { create(:ci_pipeline) } it "returns merge_request in case of success" do @@ -1579,6 +1637,15 @@ describe API::MergeRequests do expect(merge_request.reload.state).to eq('opened') end + it 'merges if the head pipeline already succeeded and `merge_when_pipeline_succeeds` is passed' do + create(:ci_pipeline, :success, sha: merge_request.diff_head_sha, merge_requests_as_head_pipeline: [merge_request]) + + put api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/merge", user), params: { merge_when_pipeline_succeeds: true } + + expect(response).to have_gitlab_http_status(200) + expect(json_response['state']).to eq('merged') + end + it "enables merge when pipeline succeeds if the pipeline is active" do allow_any_instance_of(MergeRequest).to receive_messages(head_pipeline: pipeline, actual_head_pipeline: pipeline) allow(pipeline).to receive(:active?).and_return(true) @@ -2155,16 +2222,34 @@ describe API::MergeRequests do 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) + context 'when rebase can be performed' do + it 'enqueues a rebase of the merge request against the target branch' do + Sidekiq::Testing.fake! do + expect do + put api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/rebase", user) + end.to change { RebaseWorker.jobs.size }.by(1) + end + + expect(response).to have_gitlab_http_status(202) + expect(merge_request.reload).to be_rebase_in_progress + expect(json_response['rebase_in_progress']).to be(true) end - expect(response).to have_gitlab_http_status(202) - expect(RebaseWorker.jobs.size).to eq(1) + context 'when skip_ci parameter is set' do + it 'enqueues a rebase of the merge request with skip_ci flag set' do + expect(RebaseWorker).to receive(:perform_async).with(merge_request.id, user.id, true).and_call_original - expect(merge_request.reload).to be_rebase_in_progress - expect(json_response['rebase_in_progress']).to be(true) + Sidekiq::Testing.fake! do + expect do + put api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/rebase", user), params: { skip_ci: true } + end.to change { RebaseWorker.jobs.size }.by(1) + end + + expect(response).to have_gitlab_http_status(202) + expect(merge_request.reload).to be_rebase_in_progress + expect(json_response['rebase_in_progress']).to be(true) + end + end end it 'returns 403 if the user cannot push to the branch' do @@ -2193,7 +2278,7 @@ describe API::MergeRequests do put api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/rebase", user) expect(response).to have_gitlab_http_status(409) - expect(json_response['message']).to eq(MergeRequest::REBASE_LOCK_MESSAGE) + expect(json_response['message']).to eq('Failed to enqueue the rebase operation, possibly due to a long-lived transaction. Try again later.') end end diff --git a/spec/requests/api/notes_spec.rb b/spec/requests/api/notes_spec.rb index cc2038a7245222a11ab967e61a470591177ccd62..b4416344ecf01fa0ce6420e9622959c4192d269d 100644 --- a/spec/requests/api/notes_spec.rb +++ b/spec/requests/api/notes_spec.rb @@ -101,6 +101,75 @@ describe API::Notes do expect(json_response.first['body']).to eq(cross_reference_note.note) end end + + context "activity filters" do + let!(:user_reference_note) do + create :note, + noteable: ext_issue, project: ext_proj, + note: "Hello there general!", + system: false + end + + let(:test_url) {"/projects/#{ext_proj.id}/issues/#{ext_issue.iid}/notes"} + + shared_examples 'a notes request' do + it 'is a note array response' do + expect(response).to have_gitlab_http_status(200) + expect(response).to include_pagination_headers + expect(json_response).to be_an Array + end + end + + context "when not provided" do + let(:count) { 2 } + + before do + get api(test_url, private_user) + end + + it_behaves_like 'a notes request' + + it 'returns all the notes' do + expect(json_response.count).to eq(count) + end + end + + context "when all_notes provided" do + let(:count) { 2 } + + before do + get api(test_url + "?activity_filter=all_notes", private_user) + end + + it_behaves_like 'a notes request' + + it 'returns all the notes' do + expect(json_response.count).to eq(count) + end + end + + context "when provided" do + using RSpec::Parameterized::TableSyntax + + where(:filter, :count, :system_notable) do + "only_comments" | 1 | false + "only_activity" | 1 | true + end + + with_them do + before do + get api(test_url + "?activity_filter=#{filter}", private_user) + end + + it_behaves_like 'a notes request' + + it "properly filters the returned notables" do + expect(json_response.count).to eq(count) + expect(json_response.first["system"]).to be system_notable + end + end + end + end end describe "GET /projects/:id/noteable/:noteable_id/notes/:note_id" do diff --git a/spec/requests/api/pipelines_spec.rb b/spec/requests/api/pipelines_spec.rb index a9d570b569609045ca58c777ef1380b7f8ecbd21..75e3013d36294c48635c1fea64232ad10de98cdd 100644 --- a/spec/requests/api/pipelines_spec.rb +++ b/spec/requests/api/pipelines_spec.rb @@ -254,9 +254,7 @@ describe API::Pipelines do context 'when order_by and sort are specified' do context 'when order_by user_id' do before do - 3.times do - create(:ci_pipeline, project: project, user: create(:user)) - end + create_list(:ci_pipeline, 3, project: project, user: create(:user)) end context 'when sort parameter is valid' do diff --git a/spec/requests/api/projects_spec.rb b/spec/requests/api/projects_spec.rb index 9af4f484f9993c76cb2d0b528ff43f17d5eb9c31..fce49d0248c3351185d1578d5384c922a92d9e4e 100644 --- a/spec/requests/api/projects_spec.rb +++ b/spec/requests/api/projects_spec.rb @@ -570,6 +570,102 @@ describe API::Projects do let(:projects) { Project.all } end end + + context 'with keyset pagination' do + let(:current_user) { user } + let(:projects) { [public_project, project, project2, project3] } + + context 'headers and records' do + let(:params) { { pagination: 'keyset', order_by: :id, sort: :asc, per_page: 1 } } + + it 'includes a pagination header with link to the next page' do + get api('/projects', current_user), params: params + + expect(response.header).to include('Links') + expect(response.header['Links']).to include('pagination=keyset') + expect(response.header['Links']).to include("id_after=#{public_project.id}") + end + + it 'contains only the first project with per_page = 1' do + get api('/projects', current_user), params: params + + expect(response).to have_gitlab_http_status(200) + expect(json_response).to be_an Array + expect(json_response.map { |p| p['id'] }).to contain_exactly(public_project.id) + end + + it 'still includes a link if the end has reached and there is no more data after this page' do + get api('/projects', current_user), params: params.merge(id_after: project2.id) + + expect(response.header).to include('Links') + expect(response.header['Links']).to include('pagination=keyset') + expect(response.header['Links']).to include("id_after=#{project3.id}") + end + + it 'does not include a next link when the page does not have any records' do + get api('/projects', current_user), params: params.merge(id_after: Project.maximum(:id)) + + expect(response.header).not_to include('Links') + end + + it 'returns an empty array when the page does not have any records' do + get api('/projects', current_user), params: params.merge(id_after: Project.maximum(:id)) + + expect(response).to have_gitlab_http_status(200) + expect(json_response).to eq([]) + end + + it 'responds with 501 if order_by is different from id' do + get api('/projects', current_user), params: params.merge(order_by: :created_at) + + expect(response).to have_gitlab_http_status(405) + end + end + + context 'with descending sorting' do + let(:params) { { pagination: 'keyset', order_by: :id, sort: :desc, per_page: 1 } } + + it 'includes a pagination header with link to the next page' do + get api('/projects', current_user), params: params + + expect(response.header).to include('Links') + expect(response.header['Links']).to include('pagination=keyset') + expect(response.header['Links']).to include("id_before=#{project3.id}") + end + + it 'contains only the last project with per_page = 1' do + get api('/projects', current_user), params: params + + expect(response).to have_gitlab_http_status(200) + expect(json_response).to be_an Array + expect(json_response.map { |p| p['id'] }).to contain_exactly(project3.id) + end + end + + context 'retrieving the full relation' do + let(:params) { { pagination: 'keyset', order_by: :id, sort: :desc, per_page: 2 } } + + it 'returns all projects' do + url = '/projects' + requests = 0 + ids = [] + + while url && requests <= 5 # circuit breaker + requests += 1 + get api(url, current_user), params: params + + links = response.header['Links'] + url = links&.match(/<[^>]+(\/projects\?[^>]+)>; rel="next"/) do |match| + match[1] + end + + ids += JSON.parse(response.body).map { |p| p['id'] } + end + + expect(ids).to contain_exactly(*projects.map(&:id)) + end + end + end end describe 'POST /projects' do @@ -635,6 +731,7 @@ describe API::Projects do wiki_enabled: false, resolve_outdated_diff_discussions: false, remove_source_branch_after_merge: true, + autoclose_referenced_issues: true, only_allow_merge_if_pipeline_succeeds: false, request_access_enabled: true, only_allow_merge_if_all_discussions_are_resolved: false, @@ -807,6 +904,22 @@ describe API::Projects do expect(json_response['only_allow_merge_if_all_discussions_are_resolved']).to be_truthy end + it 'sets a project as enabling auto close referenced issues' do + project = attributes_for(:project, autoclose_referenced_issues: true) + + post api('/projects', user), params: project + + expect(json_response['autoclose_referenced_issues']).to be_truthy + end + + it 'sets a project as disabling auto close referenced issues' do + project = attributes_for(:project, autoclose_referenced_issues: false) + + post api('/projects', user), params: project + + expect(json_response['autoclose_referenced_issues']).to be_falsey + end + it 'sets the merge method of a project to rebase merge' do project = attributes_for(:project, merge_method: 'rebase_merge') @@ -1626,6 +1739,14 @@ describe API::Projects do end end end + + it_behaves_like 'storing arguments in the application context' do + let_it_be(:user) { create(:user) } + let_it_be(:project) { create(:project, :public) } + let(:expected_params) { { user: user.username, project: project.full_path } } + + subject { get api("/projects/#{project.id}", user) } + end end describe 'GET /projects/:id/users' do @@ -2226,6 +2347,22 @@ describe API::Projects do put api("/projects/#{project3.id}", user4), params: project_param expect(response).to have_gitlab_http_status(403) end + + it 'updates container_expiration_policy' do + project_param = { + container_expiration_policy_attributes: { + cadence: '1month', + keep_n: 1 + } + } + + put api("/projects/#{project3.id}", user4), params: project_param + + expect(response).to have_gitlab_http_status(200) + + expect(json_response['container_expiration_policy']['cadence']).to eq('1month') + expect(json_response['container_expiration_policy']['keep_n']).to eq(1) + end end context 'when authenticated as project developer' do @@ -2721,6 +2858,20 @@ describe API::Projects do expect(json_response['message']).to eq('401 Unauthorized') end end + + context 'forking disabled' do + before do + project.project_feature.update_attribute( + :forking_access_level, ProjectFeature::DISABLED) + end + + it 'denies project to be forked' do + post api("/projects/#{project.id}/fork", admin) + + expect(response).to have_gitlab_http_status(409) + expect(json_response['message']['forked_from_project_id']).to eq(['is forbidden']) + end + end end describe 'POST /projects/:id/housekeeping' do diff --git a/spec/requests/api/remote_mirrors_spec.rb b/spec/requests/api/remote_mirrors_spec.rb index c5ba9bd223e199b55d7914b8f1ea82620b063480..065d9c7ca5bf4ca35d12a9747b168c8f804dfade 100644 --- a/spec/requests/api/remote_mirrors_spec.rb +++ b/spec/requests/api/remote_mirrors_spec.rb @@ -5,14 +5,13 @@ require 'spec_helper' describe API::RemoteMirrors do let_it_be(:user) { create(:user) } let_it_be(:project) { create(:project, :repository, :remote_mirror) } + let_it_be(:developer) { create(:user) { |u| project.add_developer(u) } } describe 'GET /projects/:id/remote_mirrors' do let(:route) { "/projects/#{project.id}/remote_mirrors" } it 'requires `admin_remote_mirror` permission' do - project.add_developer(user) - - get api(route, user) + get api(route, developer) expect(response).to have_gitlab_http_status(:unauthorized) end @@ -26,6 +25,7 @@ describe API::RemoteMirrors do expect(response).to match_response_schema('remote_mirrors') end + # TODO: Remove flag: https://gitlab.com/gitlab-org/gitlab/issues/38121 context 'with the `remote_mirrors_api` feature disabled' do before do stub_feature_flags(remote_mirrors_api: false) @@ -38,4 +38,41 @@ describe API::RemoteMirrors do end end end + + describe 'PUT /projects/:id/remote_mirrors/:mirror_id' do + let(:route) { ->(id) { "/projects/#{project.id}/remote_mirrors/#{id}" } } + let(:mirror) { project.remote_mirrors.first } + + it 'requires `admin_remote_mirror` permission' do + put api(route[mirror.id], developer) + + expect(response).to have_gitlab_http_status(:unauthorized) + end + + it 'updates a remote mirror' do + project.add_maintainer(user) + + put api(route[mirror.id], user), params: { + enabled: '0', + only_protected_branches: 'true' + } + + expect(response).to have_gitlab_http_status(:success) + expect(json_response['enabled']).to eq(false) + expect(json_response['only_protected_branches']).to eq(true) + end + + # TODO: Remove flag: https://gitlab.com/gitlab-org/gitlab/issues/38121 + context 'with the `remote_mirrors_api` feature disabled' do + before do + stub_feature_flags(remote_mirrors_api: false) + end + + it 'responds with `not_found`' do + put api(route[mirror.id], user) + + expect(response).to have_gitlab_http_status(:not_found) + end + end + end end diff --git a/spec/requests/api/runner_spec.rb b/spec/requests/api/runner_spec.rb index cc6cadb190aeaba414b77f7dba3995e2c4786d17..a313f75e3ec92dd2decf7f4c2247d1a136b3b21d 100644 --- a/spec/requests/api/runner_spec.rb +++ b/spec/requests/api/runner_spec.rb @@ -1154,6 +1154,7 @@ describe API::Runner, :clean_gitlab_redis_shared_state do expect(job.reload.trace.raw).to eq 'BUILD TRACE appended' expect(response.header).to have_key 'Range' expect(response.header).to have_key 'Job-Status' + expect(response.header).to have_key 'X-GitLab-Trace-Update-Interval' end context 'when job has been updated recently' do @@ -1291,6 +1292,41 @@ describe API::Runner, :clean_gitlab_redis_shared_state do expect(response.header['Job-Status']).to eq 'canceled' end end + + context 'when build trace is being watched' do + before do + job.trace.being_watched! + end + + it 'returns X-GitLab-Trace-Update-Interval as 3' do + patch_the_trace + + expect(response.status).to eq 202 + expect(response.header['X-GitLab-Trace-Update-Interval']).to eq('3') + end + end + + context 'when build trace is not being watched' do + it 'returns X-GitLab-Trace-Update-Interval as 30' do + patch_the_trace + + expect(response.status).to eq 202 + expect(response.header['X-GitLab-Trace-Update-Interval']).to eq('30') + end + end + + context 'when feature flag runner_job_trace_update_interval_header is disabled' do + before do + stub_feature_flags(runner_job_trace_update_interval_header: { enabled: false }) + end + + it 'does not return X-GitLab-Trace-Update-Interval header' do + patch_the_trace + + expect(response.status).to eq 202 + expect(response.header).not_to have_key 'X-GitLab-Trace-Update-Interval' + end + end end context 'when Runner makes a force-patch' do @@ -1792,6 +1828,58 @@ describe API::Runner, :clean_gitlab_redis_shared_state do end end end + + context 'when artifact_type is metrics_referee' do + context 'when artifact_format is gzip' do + let(:file_upload) { fixture_file_upload('spec/fixtures/referees/metrics_referee.json.gz') } + let(:params) { { artifact_type: :metrics_referee, artifact_format: :gzip } } + + it 'stores metrics_referee data' do + upload_artifacts(file_upload, headers_with_token, params) + + expect(response).to have_gitlab_http_status(201) + expect(job.reload.job_artifacts_metrics_referee).not_to be_nil + end + end + + context 'when artifact_format is raw' do + let(:file_upload) { fixture_file_upload('spec/fixtures/referees/metrics_referee.json.gz') } + let(:params) { { artifact_type: :metrics_referee, artifact_format: :raw } } + + it 'returns an error' do + upload_artifacts(file_upload, headers_with_token, params) + + expect(response).to have_gitlab_http_status(400) + expect(job.reload.job_artifacts_metrics_referee).to be_nil + end + end + end + + context 'when artifact_type is network_referee' do + context 'when artifact_format is gzip' do + let(:file_upload) { fixture_file_upload('spec/fixtures/referees/network_referee.json.gz') } + let(:params) { { artifact_type: :network_referee, artifact_format: :gzip } } + + it 'stores network_referee data' do + upload_artifacts(file_upload, headers_with_token, params) + + expect(response).to have_gitlab_http_status(201) + expect(job.reload.job_artifacts_network_referee).not_to be_nil + end + end + + context 'when artifact_format is raw' do + let(:file_upload) { fixture_file_upload('spec/fixtures/referees/network_referee.json.gz') } + let(:params) { { artifact_type: :network_referee, artifact_format: :raw } } + + it 'returns an error' do + upload_artifacts(file_upload, headers_with_token, params) + + expect(response).to have_gitlab_http_status(400) + expect(job.reload.job_artifacts_network_referee).to be_nil + end + end + end end context 'when artifacts are being stored outside of tmp path' do diff --git a/spec/requests/api/services_spec.rb b/spec/requests/api/services_spec.rb index 7c7620389b40621b2f53d29d328e2d61e17ea71b..08f58387bf8262f402ac0ea901c6223b8d6b0dd2 100644 --- a/spec/requests/api/services_spec.rb +++ b/spec/requests/api/services_spec.rb @@ -10,6 +10,38 @@ describe API::Services do create(:project, creator_id: user.id, namespace: user.namespace) end + describe "GET /projects/:id/services" do + it 'returns authentication error when unauthenticated' do + get api("/projects/#{project.id}/services") + + expect(response).to have_gitlab_http_status(401) + end + + it "returns error when authenticated but user is not a project owner" do + project.add_developer(user2) + get api("/projects/#{project.id}/services", user2) + + expect(response).to have_gitlab_http_status(403) + end + + context 'project with services' do + let!(:active_service) { create(:emails_on_push_service, project: project, active: true) } + let!(:service) { create(:custom_issue_tracker_service, project: project, active: false) } + + it "returns a list of all active services" do + get api("/projects/#{project.id}/services", user) + + aggregate_failures 'expect successful response with all active services' do + expect(response).to have_gitlab_http_status(200) + expect(json_response).to be_an Array + expect(json_response.count).to eq(1) + expect(json_response.first['slug']).to eq('emails-on-push') + expect(response).to match_response_schema('public_api/v4/services') + end + end + end + end + Service.available_services_names.each do |service| describe "PUT /projects/:id/services/#{service.dasherize}" do include_context service @@ -30,6 +62,7 @@ describe API::Services do put api("/projects/#{project.id}/services/#{dashed_service}?#{query_strings}", user), params: service_attrs expect(response).to have_gitlab_http_status(200) + expect(json_response['slug']).to eq(dashed_service) events.each do |event| next if event == "foo" diff --git a/spec/requests/api/triggers_spec.rb b/spec/requests/api/triggers_spec.rb index fd1104fa97837f9f2c549867b5859c8e58943b8c..d54d112cd9fd6f406de0ee66ad5fe11a7da658f2 100644 --- a/spec/requests/api/triggers_spec.rb +++ b/spec/requests/api/triggers_spec.rb @@ -87,22 +87,6 @@ describe API::Triggers do expect(pipeline.variables.map { |v| { v.key => v.value } }.last).to eq(variables) end end - - context 'when legacy trigger' do - before do - trigger.update(owner: nil) - end - - it 'creates pipeline' do - post api("/projects/#{project.id}/trigger/pipeline"), params: options.merge(ref: 'master') - - expect(response).to have_gitlab_http_status(201) - expect(json_response).to include('id' => pipeline.id) - pipeline.builds.reload - expect(pipeline.builds.pending.size).to eq(2) - expect(pipeline.builds.size).to eq(5) - end - end end context 'when triggering a pipeline from a trigger token' do diff --git a/spec/requests/api/wikis_spec.rb b/spec/requests/api/wikis_spec.rb index 310caa92eb93de4c7892f9e46bb8a5fbf5501990..2e0b7a304805a65ca729834bdc873f709681a70a 100644 --- a/spec/requests/api/wikis_spec.rb +++ b/spec/requests/api/wikis_spec.rb @@ -115,7 +115,7 @@ describe API::Wikis do end [:title, :content, :format].each do |part| - it "it updates with wiki with missing #{part}" do + it "updates with wiki with missing #{part}" do payload.delete(part) put(api(url, user), params: payload) diff --git a/spec/requests/self_monitoring_project_spec.rb b/spec/requests/self_monitoring_project_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..d562a34aec4bda2b30695768907d2141feb27990 --- /dev/null +++ b/spec/requests/self_monitoring_project_spec.rb @@ -0,0 +1,224 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'Self-Monitoring project requests' do + let(:admin) { create(:admin) } + + describe 'POST #create_self_monitoring_project' do + let(:worker_class) { SelfMonitoringProjectCreateWorker } + + subject { post create_self_monitoring_project_admin_application_settings_path } + + it_behaves_like 'not accessible to non-admin users' + + context 'with admin user' do + before do + login_as(admin) + end + + context 'with feature flag disabled' do + it_behaves_like 'not accessible if feature flag is disabled' + end + + context 'with feature flag enabled' do + let(:status_api) { status_create_self_monitoring_project_admin_application_settings_path } + + it_behaves_like 'triggers async worker, returns sidekiq job_id with response accepted' + end + end + end + + describe 'GET #status_create_self_monitoring_project' do + let(:worker_class) { SelfMonitoringProjectCreateWorker } + let(:job_id) { 'job_id' } + + subject do + get status_create_self_monitoring_project_admin_application_settings_path, + params: { job_id: job_id } + end + + it_behaves_like 'not accessible to non-admin users' + + context 'with admin user' do + before do + login_as(admin) + end + + context 'with feature flag disabled' do + it_behaves_like 'not accessible if feature flag is disabled' + end + + context 'with feature flag enabled' do + it_behaves_like 'handles invalid job_id' + + context 'when job is in progress' do + before do + allow(worker_class).to receive(:in_progress?) + .with(job_id) + .and_return(true) + end + + it_behaves_like 'sets polling header and returns accepted' do + let(:in_progress_message) { 'Job to create self-monitoring project is in progress' } + end + end + + context 'when self-monitoring project and job do not exist' do + let(:job_id) { nil } + + it 'returns bad_request' do + subject + + aggregate_failures do + expect(response).to have_gitlab_http_status(:bad_request) + expect(json_response).to eq( + 'message' => 'Self-monitoring project does not exist. Please check logs ' \ + 'for any error messages' + ) + end + end + end + + context 'when self-monitoring project exists' do + let(:project) { build(:project) } + + before do + stub_application_setting(instance_administration_project_id: 1) + stub_application_setting(instance_administration_project: project) + end + + it 'does not need job_id' do + get status_create_self_monitoring_project_admin_application_settings_path + + aggregate_failures do + expect(response).to have_gitlab_http_status(:success) + expect(json_response).to eq( + 'project_id' => 1, + 'project_full_path' => project.full_path + ) + end + end + + it 'returns success with job_id' do + subject + + aggregate_failures do + expect(response).to have_gitlab_http_status(:success) + expect(json_response).to eq( + 'project_id' => 1, + 'project_full_path' => project.full_path + ) + end + end + end + end + end + end + + describe 'DELETE #delete_self_monitoring_project' do + let(:worker_class) { SelfMonitoringProjectDeleteWorker } + + subject { delete delete_self_monitoring_project_admin_application_settings_path } + + it_behaves_like 'not accessible to non-admin users' + + context 'with admin user' do + before do + login_as(admin) + end + + context 'with feature flag disabled' do + it_behaves_like 'not accessible if feature flag is disabled' + end + + context 'with feature flag enabled' do + let(:status_api) { status_delete_self_monitoring_project_admin_application_settings_path } + + it_behaves_like 'triggers async worker, returns sidekiq job_id with response accepted' + end + end + end + + describe 'GET #status_delete_self_monitoring_project' do + let(:worker_class) { SelfMonitoringProjectDeleteWorker } + let(:job_id) { 'job_id' } + + subject do + get status_delete_self_monitoring_project_admin_application_settings_path, + params: { job_id: job_id } + end + + it_behaves_like 'not accessible to non-admin users' + + context 'with admin user' do + before do + login_as(admin) + end + + context 'with feature flag disabled' do + it_behaves_like 'not accessible if feature flag is disabled' + end + + context 'with feature flag enabled' do + it_behaves_like 'handles invalid job_id' + + context 'when job is in progress' do + before do + allow(worker_class).to receive(:in_progress?) + .with(job_id) + .and_return(true) + + stub_application_setting(instance_administration_project_id: 1) + end + + it_behaves_like 'sets polling header and returns accepted' do + let(:in_progress_message) { 'Job to delete self-monitoring project is in progress' } + end + end + + context 'when self-monitoring project exists and job does not exist' do + before do + stub_application_setting(instance_administration_project_id: 1) + end + + it 'returns bad_request' do + subject + + aggregate_failures do + expect(response).to have_gitlab_http_status(:bad_request) + expect(json_response).to eq( + 'message' => 'Self-monitoring project was not deleted. Please check logs ' \ + 'for any error messages' + ) + end + end + end + + context 'when self-monitoring project does not exist' do + it 'does not need job_id' do + get status_delete_self_monitoring_project_admin_application_settings_path + + aggregate_failures do + expect(response).to have_gitlab_http_status(:success) + expect(json_response).to eq( + 'message' => 'Self-monitoring project has been successfully deleted' + ) + end + end + + it 'returns success with job_id' do + subject + + aggregate_failures do + expect(response).to have_gitlab_http_status(:success) + expect(json_response).to eq( + 'message' => 'Self-monitoring project has been successfully deleted' + ) + end + end + end + end + end + end +end diff --git a/spec/routing/admin_routing_spec.rb b/spec/routing/admin_routing_spec.rb index a82bdfe3ce8411f755899982292ca790e0032024..93b2c19c74a5cdafe3645f16304ee2cf1d4da793 100644 --- a/spec/routing/admin_routing_spec.rb +++ b/spec/routing/admin_routing_spec.rb @@ -161,3 +161,17 @@ describe Admin::GroupsController, "routing" do expect(get("/admin/groups/#{name}/edit")).to route_to('admin/groups#edit', id: name) end end + +describe Admin::SessionsController, "routing" do + it "to #new" do + expect(get("/admin/session/new")).to route_to('admin/sessions#new') + end + + it "to #create" do + expect(post("/admin/session")).to route_to('admin/sessions#create') + end + + it "to #destroy" do + expect(post("/admin/session/destroy")).to route_to('admin/sessions#destroy') + end +end diff --git a/spec/routing/project_routing_spec.rb b/spec/routing/project_routing_spec.rb index 287db20448aca0b5646a28c15b006102f9fdb6e3..efd7d3f374211b2c759860d106449798419e1682 100644 --- a/spec/routing/project_routing_spec.rb +++ b/spec/routing/project_routing_spec.rb @@ -314,6 +314,12 @@ describe 'project routing' do expect(get('/gitlab/gitlabhq/merge_requests/1/pipelines')).to route_to('projects/merge_requests#show', namespace_id: 'gitlab', project_id: 'gitlabhq', id: '1', tab: 'pipelines') end + it 'to #show from scoped route' do + expect(get('/gitlab/gitlabhq/-/merge_requests/1.diff')).to route_to('projects/merge_requests#show', namespace_id: 'gitlab', project_id: 'gitlabhq', id: '1', format: 'diff') + expect(get('/gitlab/gitlabhq/-/merge_requests/1.patch')).to route_to('projects/merge_requests#show', namespace_id: 'gitlab', project_id: 'gitlabhq', id: '1', format: 'patch') + expect(get('/gitlab/gitlabhq/-/merge_requests/1/diffs')).to route_to('projects/merge_requests#show', namespace_id: 'gitlab', project_id: 'gitlabhq', id: '1', tab: 'diffs') + end + it_behaves_like 'RESTful project resources' do let(:controller) { 'merge_requests' } let(:actions) { [:index, :edit, :show, :update] } @@ -573,6 +579,10 @@ describe 'project routing' do namespace_id: 'gitlab', project_id: 'gitlabhq', id: "blob/master/blob/#{newline_file}" }) end + + it 'to #show from scope routing' do + expect(get('/gitlab/gitlabhq/-/blob/master/app/models/project.rb')).to route_to('projects/blob#show', namespace_id: 'gitlab', project_id: 'gitlabhq', id: 'master/app/models/project.rb') + end end # project_tree GET /:project_id/tree/:id(.:format) tree#show {id: /[^\0]+/, project_id: /[^\/]+/} @@ -590,6 +600,10 @@ describe 'project routing' do namespace_id: 'gitlab', project_id: 'gitlabhq', id: "master/#{newline_file}" }) end + + it 'to #show from scope routing' do + expect(get('/gitlab/gitlabhq/-/tree/master/app/models/project.rb')).to route_to('projects/tree#show', namespace_id: 'gitlab', project_id: 'gitlabhq', id: 'master/app/models/project.rb') + end end # project_find_file GET /:namespace_id/:project_id/find_file/*id(.:format) projects/find_file#show {:id=>/[^\0]+/, :namespace_id=>/[a-zA-Z.0-9_\-]+/, :project_id=>/[a-zA-Z.0-9_\-]+(?<!\.atom)/, :format=>/html/} diff --git a/spec/routing/routing_spec.rb b/spec/routing/routing_spec.rb index 6f67cdb12225ca892fe15aefc0d3601f27378ce2..ff002469e3c46186fc1f8aac19df026147cb3866 100644 --- a/spec/routing/routing_spec.rb +++ b/spec/routing/routing_spec.rb @@ -256,10 +256,8 @@ describe "Authentication", "routing" do expect(post("/users/sign_in")).to route_to('sessions#create') end - # sign_out with GET instead of DELETE facilitates ad-hoc single-sign-out processes - # (https://gitlab.com/gitlab-org/gitlab-foss/issues/39708) - it "GET /users/sign_out" do - expect(get("/users/sign_out")).to route_to('sessions#destroy') + it "POST /users/sign_out" do + expect(post("/users/sign_out")).to route_to('sessions#destroy') end it "POST /users/password" do diff --git a/spec/routing/uploads_routing_spec.rb b/spec/routing/uploads_routing_spec.rb index 42e84774088baad7a21fdc2cf0efdbfc4f9b60ad..f94ae81eeb5b857f09492ba2e44f7977312ffe97 100644 --- a/spec/routing/uploads_routing_spec.rb +++ b/spec/routing/uploads_routing_spec.rb @@ -28,4 +28,12 @@ describe 'Uploads', 'routing' do expect(post("/uploads/#{model}?id=1")).not_to be_routable end end + + describe 'legacy paths' do + include RSpec::Rails::RequestExampleGroup + + it 'redirects project uploads to canonical path under project namespace' do + expect(get('/uploads/namespace/project/12345/test.png')).to redirect_to('/namespace/project/uploads/12345/test.png') + end + end end diff --git a/spec/rubocop/cop/migration/add_column_with_default_spec.rb b/spec/rubocop/cop/migration/add_column_with_default_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..f3518f2f058bb5e89e0872324d18d73e66223711 --- /dev/null +++ b/spec/rubocop/cop/migration/add_column_with_default_spec.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require 'spec_helper' + +require 'rubocop' +require 'rubocop/rspec/support' + +require_relative '../../../../rubocop/cop/migration/add_column_with_default' + +describe RuboCop::Cop::Migration::AddColumnWithDefault do + include CopHelper + + let(:cop) { described_class.new } + + context 'outside of a migration' do + it 'does not register any offenses' do + expect_no_offenses(<<~RUBY) + def up + add_column_with_default(:ci_build_needs, :artifacts, :boolean, default: true, allow_null: false) + end + RUBY + end + end + + context 'in a migration' do + before do + allow(cop).to receive(:in_migration?).and_return(true) + end + + let(:offense) { '`add_column_with_default` without `allow_null: true` may cause prolonged lock situations and downtime, see https://gitlab.com/gitlab-org/gitlab/issues/38060' } + + it 'registers an offense when specifying allow_null: false' do + expect_offense(<<~RUBY) + def up + add_column_with_default(:ci_build_needs, :artifacts, :boolean, default: true, allow_null: false) + ^^^^^^^^^^^^^^^^^^^^^^^ #{offense} + end + RUBY + end + + it 'registers no offense when specifying allow_null: true' do + expect_no_offenses(<<~RUBY) + def up + add_column_with_default(:ci_build_needs, :artifacts, :boolean, default: true, allow_null: true) + end + RUBY + end + + it 'registers an offense when allow_null is not specified' do + expect_offense(<<~RUBY) + def up + add_column_with_default(:ci_build_needs, :artifacts, :boolean, default: true) + ^^^^^^^^^^^^^^^^^^^^^^^ #{offense} + end + RUBY + end + + it 'registers no offense for application_settings (whitelisted table)' do + expect_no_offenses(<<~RUBY) + def up + add_column_with_default(:application_settings, :another_column, :boolean, default: true, allow_null: false) + end + RUBY + end + end +end diff --git a/spec/rubocop/cop/rspec/have_gitlab_http_status_spec.rb b/spec/rubocop/cop/rspec/have_gitlab_http_status_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..12bdacdee3c539a407e64b009a7ad2bc37f30d61 --- /dev/null +++ b/spec/rubocop/cop/rspec/have_gitlab_http_status_spec.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +require 'fast_spec_helper' + +require 'rspec-parameterized' +require 'rubocop' +require 'rubocop/rspec/support' + +require_relative '../../../../rubocop/cop/rspec/have_gitlab_http_status' + +describe RuboCop::Cop::RSpec::HaveGitlabHttpStatus do + include CopHelper + + using RSpec::Parameterized::TableSyntax + + let(:source_file) { 'spec/foo_spec.rb' } + + subject(:cop) { described_class.new } + + shared_examples 'offense' do |code| + it 'registers an offense' do + inspect_source(code, source_file) + + expect(cop.offenses.size).to eq(1) + expect(cop.offenses.map(&:line)).to eq([1]) + expect(cop.highlights).to eq([code]) + end + end + + shared_examples 'no offense' do |code| + it 'does not register an offense' do + inspect_source(code) + + expect(cop.offenses).to be_empty + end + end + + shared_examples 'autocorrect' do |bad, good| + it 'autocorrects' do + autocorrected = autocorrect_source(bad, source_file) + + expect(autocorrected).to eql(good) + end + end + + shared_examples 'no autocorrect' do |code| + it 'does not autocorrect' do + autocorrected = autocorrect_source(code, source_file) + + expect(autocorrected).to eql(code) + end + end + + describe 'offenses and autocorrections' do + where(:bad, :good) do + 'have_http_status(:ok)' | 'have_gitlab_http_status(:ok)' + 'have_http_status(204)' | 'have_gitlab_http_status(:no_content)' + 'have_gitlab_http_status(201)' | 'have_gitlab_http_status(:created)' + 'have_http_status(var)' | 'have_gitlab_http_status(var)' + 'have_http_status(:success)' | 'have_gitlab_http_status(:success)' + 'have_http_status(:invalid)' | 'have_gitlab_http_status(:invalid)' + end + + with_them do + include_examples 'offense', params[:bad] + include_examples 'no offense', params[:good] + include_examples 'autocorrect', params[:bad], params[:good] + include_examples 'no autocorrect', params[:good] + end + end + + describe 'partially autocorrects invalid numeric status' do + where(:bad, :good) do + 'have_http_status(-1)' | 'have_gitlab_http_status(-1)' + end + + with_them do + include_examples 'offense', params[:bad] + include_examples 'offense', params[:good] + include_examples 'autocorrect', params[:bad], params[:good] + include_examples 'no autocorrect', params[:good] + end + end + + describe 'ignore' do + where(:code) do + [ + 'have_http_status', + 'have_http_status { }', + 'have_http_status(200, arg)', + 'have_gitlab_http_status', + 'have_gitlab_http_status { }', + 'have_gitlab_http_status(200, arg)' + ] + end + + with_them do + include_examples 'no offense', params[:code] + include_examples 'no autocorrect', params[:code] + end + end +end diff --git a/spec/serializers/deploy_key_entity_spec.rb b/spec/serializers/deploy_key_entity_spec.rb index 607adfc24886f098e7669cc3af902073ee060d44..0dbbf0de59bdfbb254e030d225266a9c6106e3b0 100644 --- a/spec/serializers/deploy_key_entity_spec.rb +++ b/spec/serializers/deploy_key_entity_spec.rb @@ -24,6 +24,7 @@ describe DeployKeyEntity do user_id: deploy_key.user_id, title: deploy_key.title, fingerprint: deploy_key.fingerprint, + fingerprint_sha256: deploy_key.fingerprint_sha256, destroyed_when_orphaned: true, almost_orphaned: false, created_at: deploy_key.created_at, diff --git a/spec/serializers/deployment_entity_spec.rb b/spec/serializers/deployment_entity_spec.rb index 2a57ea51b39b0a53c99aa9f46c7684b88fd276d5..7abe74fae8f9a6a600ffdec3effc0b83706473a7 100644 --- a/spec/serializers/deployment_entity_spec.rb +++ b/spec/serializers/deployment_entity_spec.rb @@ -6,7 +6,7 @@ describe DeploymentEntity do let(:user) { developer } let(:developer) { create(:user) } let(:reporter) { create(:user) } - let(:project) { create(:project) } + let(:project) { create(:project, :repository) } let(:request) { double('request') } let(:deployment) { create(:deployment, deployable: build, project: project) } let(:build) { create(:ci_build, :manual, pipeline: pipeline) } diff --git a/spec/serializers/environment_status_entity_spec.rb b/spec/serializers/environment_status_entity_spec.rb index 6d98f91cfde5c5384519167b0f35978e78afae19..11455c57677db0cbe3804572c66db8b6bff9e9f3 100644 --- a/spec/serializers/environment_status_entity_spec.rb +++ b/spec/serializers/environment_status_entity_spec.rb @@ -45,7 +45,7 @@ describe EnvironmentStatusEntity do end context 'when deployment has metrics' do - let(:prometheus_adapter) { double('prometheus_adapter', can_query?: true) } + let(:prometheus_adapter) { double('prometheus_adapter', can_query?: true, configured?: true) } let(:simple_metrics) do { diff --git a/spec/serializers/issue_board_entity_spec.rb b/spec/serializers/issue_board_entity_spec.rb index f6fa2a794f6ed1da808170493eea0c8c34e46663..d013b27369b82411e6ad4a50315dc418f70783a4 100644 --- a/spec/serializers/issue_board_entity_spec.rb +++ b/spec/serializers/issue_board_entity_spec.rb @@ -3,12 +3,12 @@ require 'spec_helper' describe IssueBoardEntity do - let(:project) { create(:project) } - let(:resource) { create(:issue, project: project) } - let(:user) { create(:user) } - let(:milestone) { create(:milestone, project: project) } - let(:label) { create(:label, project: project, title: 'Test Label') } - let(:request) { double('request', current_user: user) } + let_it_be(:project) { create(:project) } + let_it_be(:resource) { create(:issue, project: project) } + let_it_be(:user) { create(:user) } + let_it_be(:milestone) { create(:milestone, project: project) } + let_it_be(:label) { create(:label, project: project, title: 'Test Label') } + let(:request) { double('request', current_user: user) } subject { described_class.new(resource, request: request).as_json } diff --git a/spec/serializers/pipeline_entity_spec.rb b/spec/serializers/pipeline_entity_spec.rb index d95aaf3d104539ec6cc22315f7df6537bc950b64..75f3bdfcc9eb9f211d4cf91762954b3caf81f209 100644 --- a/spec/serializers/pipeline_entity_spec.rb +++ b/spec/serializers/pipeline_entity_spec.rb @@ -123,6 +123,26 @@ describe PipelineEntity do end end + context 'delete path' do + context 'user has ability to delete pipeline' do + let(:project) { create(:project, namespace: user.namespace) } + let(:pipeline) { create(:ci_pipeline, project: project) } + + it 'contains delete path' do + expect(subject[:delete_path]).to be_present + end + end + + context 'user does not have ability to delete pipeline' do + let(:project) { create(:project) } + let(:pipeline) { create(:ci_pipeline, project: project) } + + it 'does not contain delete path' do + expect(subject).not_to have_key(:delete_path) + end + end + end + context 'when pipeline ref is empty' do let(:pipeline) { create(:ci_empty_pipeline) } diff --git a/spec/serializers/review_app_setup_entity_spec.rb b/spec/serializers/review_app_setup_entity_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..19949fa92826ac4326a81591354fbe610b13973b --- /dev/null +++ b/spec/serializers/review_app_setup_entity_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe ReviewAppSetupEntity do + let_it_be(:user) { create(:admin) } + let(:project) { create(:project) } + let(:presenter) { ProjectPresenter.new(project, current_user: user) } + let(:entity) { described_class.new(presenter) } + let(:request) { double('request') } + + before do + allow(request).to receive(:current_user).and_return(user) + allow(request).to receive(:project).and_return(project) + end + + subject { entity.as_json } + + describe '#as_json' do + it 'contains can_setup_review_app' do + expect(subject).to include(:can_setup_review_app) + end + + context 'when the user can setup a review app' do + before do + allow(presenter).to receive(:can_setup_review_app?).and_return(true) + end + + it 'contains relevant fields' do + expect(subject.keys).to include(:all_clusters_empty, :review_snippet) + end + + it 'exposes the relevant review snippet' do + review_app_snippet = YAML.safe_load(File.read(Rails.root.join('lib', 'gitlab', 'ci', 'snippets', 'review_app_default.yml'))).to_s + + expect(subject[:review_snippet]).to eq(review_app_snippet) + end + + it 'exposes whether the project has associated clusters' do + expect(subject[:all_clusters_empty]).to be_truthy + end + end + + context 'when the user cannot setup a review app' do + before do + allow(presenter).to receive(:can_setup_review_app?).and_return(false) + end + + it 'does not expose certain fields' do + expect(subject.keys).not_to include(:all_clusters_empty, :review_snippet) + end + end + end +end diff --git a/spec/services/auto_merge/merge_when_pipeline_succeeds_service_spec.rb b/spec/services/auto_merge/merge_when_pipeline_succeeds_service_spec.rb index f2cda999932d7baf9ec176d7488a79101669ba71..e03d87e9d497cd1826b6190217921c0c8768bbf0 100644 --- a/spec/services/auto_merge/merge_when_pipeline_succeeds_service_spec.rb +++ b/spec/services/auto_merge/merge_when_pipeline_succeeds_service_spec.rb @@ -34,7 +34,7 @@ describe AutoMerge::MergeWhenPipelineSucceedsService do it { is_expected.to be_truthy } - context 'when the head piipeline succeeded' do + context 'when the head pipeline succeeded' do let(:pipeline_status) { :success } it { is_expected.to be_falsy } diff --git a/spec/services/auto_merge_service_spec.rb b/spec/services/auto_merge_service_spec.rb index 50dfc49a59cecbb34c53b821c29a7e542eec5709..221cf6953311a38c53ed95c462b33b862d9ff4f0 100644 --- a/spec/services/auto_merge_service_spec.rb +++ b/spec/services/auto_merge_service_spec.rb @@ -3,8 +3,8 @@ require 'spec_helper' describe AutoMergeService do - set(:project) { create(:project) } - set(:user) { create(:user) } + let_it_be(:project) { create(:project) } + let_it_be(:user) { create(:user) } let(:service) { described_class.new(project, user) } describe '.all_strategies' do diff --git a/spec/services/award_emojis/add_service_spec.rb b/spec/services/award_emojis/add_service_spec.rb index 8364e662735b75b7242d945a2ad1c930f17e0378..4bcb5fa039f3dd63f6f4a11752fbcd3ca579f98f 100644 --- a/spec/services/award_emojis/add_service_spec.rb +++ b/spec/services/award_emojis/add_service_spec.rb @@ -3,9 +3,9 @@ require 'spec_helper' describe AwardEmojis::AddService do - set(:user) { create(:user) } - set(:project) { create(:project) } - set(:awardable) { create(:note, project: project) } + let_it_be(:user) { create(:user) } + let_it_be(:project) { create(:project) } + let_it_be(:awardable) { create(:note, project: project) } let(:name) { 'thumbsup' } subject(:service) { described_class.new(awardable, name, user) } diff --git a/spec/services/award_emojis/destroy_service_spec.rb b/spec/services/award_emojis/destroy_service_spec.rb index 6d54c0374644f0a2003e1de38be885f9cc4901b0..f411345560e58eb608d76e264d8266e0d9b64009 100644 --- a/spec/services/award_emojis/destroy_service_spec.rb +++ b/spec/services/award_emojis/destroy_service_spec.rb @@ -3,9 +3,9 @@ require 'spec_helper' describe AwardEmojis::DestroyService do - set(:user) { create(:user) } - set(:awardable) { create(:note) } - set(:project) { awardable.project } + let_it_be(:user) { create(:user) } + let_it_be(:awardable) { create(:note) } + let_it_be(:project) { awardable.project } let(:name) { 'thumbsup' } let!(:award_from_other_user) do create(:award_emoji, name: name, awardable: awardable, user: create(:user)) diff --git a/spec/services/award_emojis/toggle_service_spec.rb b/spec/services/award_emojis/toggle_service_spec.rb index a8d110d04f769e878616bcfa489c8a2aaa8e14d8..069bdfcb99f8288e19fe8d30f74b9a9a0b0e87fd 100644 --- a/spec/services/award_emojis/toggle_service_spec.rb +++ b/spec/services/award_emojis/toggle_service_spec.rb @@ -3,9 +3,9 @@ require 'spec_helper' describe AwardEmojis::ToggleService do - set(:user) { create(:user) } - set(:project) { create(:project, :public) } - set(:awardable) { create(:note, project: project) } + let_it_be(:user) { create(:user) } + let_it_be(:project) { create(:project, :public) } + let_it_be(:awardable) { create(:note, project: project) } let(:name) { 'thumbsup' } subject(:service) { described_class.new(awardable, name, user) } diff --git a/spec/services/boards/issues/move_service_spec.rb b/spec/services/boards/issues/move_service_spec.rb index cf84ec8fd4c18afe882be8fd2f01979bd0120c31..b9ebbc30c1a3bf17bd1032c2c9a55695dfb2c14f 100644 --- a/spec/services/boards/issues/move_service_spec.rb +++ b/spec/services/boards/issues/move_service_spec.rb @@ -54,14 +54,14 @@ describe Boards::Issues::MoveService do end describe '#execute_multiple' do - set(:group) { create(:group) } - set(:user) { create(:user) } - set(:project) { create(:project, namespace: group) } - set(:board1) { create(:board, group: group) } - set(:development) { create(:group_label, group: group, name: 'Development') } - set(:testing) { create(:group_label, group: group, name: 'Testing') } - set(:list1) { create(:list, board: board1, label: development, position: 0) } - set(:list2) { create(:list, board: board1, label: testing, position: 1) } + let_it_be(:group) { create(:group) } + let_it_be(:user) { create(:user) } + let_it_be(:project) { create(:project, namespace: group) } + let_it_be(:board1) { create(:board, group: group) } + let_it_be(:development) { create(:group_label, group: group, name: 'Development') } + let_it_be(:testing) { create(:group_label, group: group, name: 'Testing') } + let_it_be(:list1) { create(:list, board: board1, label: development, position: 0) } + let_it_be(:list2) { create(:list, board: board1, label: testing, position: 1) } let(:params) { { board_id: board1.id, from_list_id: list1.id, to_list_id: list2.id } } before do diff --git a/spec/services/boards/list_service_spec.rb b/spec/services/boards/list_service_spec.rb index c9d372ea16682abfd19b9dd0b218f9a9acb2f683..4eb023907fa4a72b0df8fdcf678a80d5258c0a14 100644 --- a/spec/services/boards/list_service_spec.rb +++ b/spec/services/boards/list_service_spec.rb @@ -10,6 +10,7 @@ describe Boards::ListService do subject(:service) { described_class.new(parent, double) } it_behaves_like 'boards list service' + it_behaves_like 'multiple boards list service' end context 'when board parent is a group' do diff --git a/spec/services/ci/create_pipeline_service/custom_config_content_spec.rb b/spec/services/ci/create_pipeline_service/custom_config_content_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..33cd6e164b05ef72da49200b01f895f22269e01e --- /dev/null +++ b/spec/services/ci/create_pipeline_service/custom_config_content_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true +require 'spec_helper' + +describe Ci::CreatePipelineService do + let_it_be(:project) { create(:project, :repository) } + let_it_be(:user) { create(:admin) } + let(:ref) { 'refs/heads/master' } + let(:service) { described_class.new(project, user, { ref: ref }) } + + context 'custom config content' do + let(:bridge) do + double(:bridge, yaml_for_downstream: <<~YML + rspec: + script: rspec + custom: + script: custom + YML + ) + end + + subject { service.execute(:push, bridge: bridge) } + + it 'creates a pipeline using the content passed in as param' do + expect(subject).to be_persisted + expect(subject.builds.map(&:name)).to eq %w[rspec custom] + expect(subject.config_source).to eq 'bridge_source' + end + end +end diff --git a/spec/services/ci/create_pipeline_service_spec.rb b/spec/services/ci/create_pipeline_service_spec.rb index 04e57b1a2d47b85dc0d921870ca88ab80d7e2e9a..d6cc233088d6a465c4486da339af831073b3d042 100644 --- a/spec/services/ci/create_pipeline_service_spec.rb +++ b/spec/services/ci/create_pipeline_service_spec.rb @@ -5,7 +5,7 @@ require 'spec_helper' describe Ci::CreatePipelineService do include ProjectForksHelper - set(:project) { create(:project, :repository) } + let_it_be(:project, reload: true) { create(:project, :repository) } let(:user) { create(:admin) } let(:ref_name) { 'refs/heads/master' } @@ -362,11 +362,11 @@ describe Ci::CreatePipelineService do context 'when build that is not marked as interruptible is running' do it 'cancels running outdated pipelines', :sidekiq_might_not_need_inline do - pipeline_on_previous_commit - .builds - .find_by_name('build_2_1') - .tap(&:enqueue!) - .run! + build_2_1 = pipeline_on_previous_commit + .builds.find_by_name('build_2_1') + + build_2_1.enqueue! + build_2_1.reset.run! pipeline @@ -377,12 +377,12 @@ describe Ci::CreatePipelineService do end context 'when an uninterruptible build is running' do - it 'does not cancel running outdated pipelines', :sidekiq_might_not_need_inline do - pipeline_on_previous_commit - .builds - .find_by_name('build_3_1') - .tap(&:enqueue!) - .run! + it 'does not cancel running outdated pipelines', :sidekiq_inline do + build_3_1 = pipeline_on_previous_commit + .builds.find_by_name('build_3_1') + + build_3_1.enqueue! + build_3_1.reset.run! pipeline @@ -493,12 +493,13 @@ describe Ci::CreatePipelineService do before do stub_ci_pipeline_yaml_file(nil) allow_any_instance_of(Project).to receive(:auto_devops_enabled?).and_return(true) + create(:project_auto_devops, project: project) end it 'pull it from Auto-DevOps' do pipeline = execute_service expect(pipeline).to be_auto_devops_source - expect(pipeline.builds.map(&:name)).to eq %w[test code_quality build] + expect(pipeline.builds.map(&:name)).to match_array(%w[test code_quality build]) end end @@ -914,6 +915,44 @@ describe Ci::CreatePipelineService do end end + context 'with resource group' do + context 'when resource group is defined' do + before do + config = YAML.dump( + test: { stage: 'test', script: 'ls', resource_group: resource_group_key } + ) + + stub_ci_pipeline_yaml_file(config) + end + + let(:resource_group_key) { 'iOS' } + + it 'persists the association correctly' do + result = execute_service + deploy_job = result.builds.find_by_name!(:test) + resource_group = project.resource_groups.find_by_key!(resource_group_key) + + expect(result).to be_persisted + expect(deploy_job.resource_group.key).to eq(resource_group_key) + expect(project.resource_groups.count).to eq(1) + expect(resource_group.builds.count).to eq(1) + expect(resource_group.resources.count).to eq(1) + expect(resource_group.resources.first.build).to eq(nil) + end + + context 'when resource group key includes predefined variables' do + let(:resource_group_key) { '$CI_COMMIT_REF_NAME-$CI_JOB_NAME' } + + it 'interpolates the variables into the key correctly' do + result = execute_service + + expect(result).to be_persisted + expect(project.resource_groups.exists?(key: 'master-test')).to eq(true) + end + end + end + end + context 'with timeout' do context 'when builds with custom timeouts are configured' do before do @@ -930,6 +969,70 @@ describe Ci::CreatePipelineService do end end + context 'with release' do + shared_examples_for 'a successful release pipeline' do + before do + stub_feature_flags(ci_release_generation: true) + stub_ci_pipeline_yaml_file(YAML.dump(config)) + end + + it 'is valid config' do + pipeline = execute_service + build = pipeline.builds.first + expect(pipeline).to be_kind_of(Ci::Pipeline) + expect(pipeline).to be_valid + expect(pipeline.yaml_errors).not_to be_present + expect(pipeline).to be_persisted + expect(build).to be_kind_of(Ci::Build) + expect(build.options).to eq(config[:release].except(:stage, :only).with_indifferent_access) + end + end + + context 'simple example' do + it_behaves_like 'a successful release pipeline' do + let(:config) do + { + release: { + script: ["make changelog | tee release_changelog.txt"], + release: { + tag_name: "v0.06", + description: "./release_changelog.txt" + } + } + } + end + end + end + + context 'example with all release metadata' do + it_behaves_like 'a successful release pipeline' do + let(:config) do + { + release: { + script: ["make changelog | tee release_changelog.txt"], + release: { + name: "Release $CI_TAG_NAME", + tag_name: "v0.06", + description: "./release_changelog.txt", + assets: { + links: [ + { + name: "cool-app.zip", + url: "http://my.awesome.download.site/1.0-$CI_COMMIT_SHORT_SHA.zip" + }, + { + url: "http://my.awesome.download.site/1.0-$CI_COMMIT_SHORT_SHA.exe" + } + ] + } + } + } + } + end + end + end + end + shared_examples 'when ref is protected' do let(:user) { create(:user) } @@ -1020,21 +1123,6 @@ describe Ci::CreatePipelineService do it_behaves_like 'when ref is protected' end - context 'when ref is not protected' do - context 'when trigger belongs to no one' do - let(:user) {} - let(:trigger) { create(:ci_trigger, owner: nil) } - let(:trigger_request) { create(:ci_trigger_request, trigger: trigger) } - let(:pipeline) { execute_service(trigger_request: trigger_request) } - - it 'creates an unprotected pipeline' do - expect(pipeline).to be_persisted - expect(pipeline).not_to be_protected - expect(Ci::Pipeline.count).to eq(1) - end - end - end - context 'when pipeline is running for a tag' do before do config = YAML.dump(test: { script: 'test', only: ['branches'] }, diff --git a/spec/services/ci/ensure_stage_service_spec.rb b/spec/services/ci/ensure_stage_service_spec.rb index 43bbd2130a40731ec9a890997f576313dcc1a8c1..de07a1ae238c4d7b0f05cc0ddbd579ea1b1fff58 100644 --- a/spec/services/ci/ensure_stage_service_spec.rb +++ b/spec/services/ci/ensure_stage_service_spec.rb @@ -3,8 +3,8 @@ require 'spec_helper' describe Ci::EnsureStageService, '#execute' do - set(:project) { create(:project) } - set(:user) { create(:user) } + let_it_be(:project) { create(:project) } + let_it_be(:user) { create(:user) } let(:stage) { create(:ci_stage_entity) } let(:job) { build(:ci_build) } diff --git a/spec/services/ci/expire_pipeline_cache_service_spec.rb b/spec/services/ci/expire_pipeline_cache_service_spec.rb index ff2d286465a2c5df04aa24ca29bcc12383ad0fd8..c0226654fd9953802b36f874745a797fe3568286 100644 --- a/spec/services/ci/expire_pipeline_cache_service_spec.rb +++ b/spec/services/ci/expire_pipeline_cache_service_spec.rb @@ -3,9 +3,9 @@ require 'spec_helper' describe Ci::ExpirePipelineCacheService do - set(:user) { create(:user) } - set(:project) { create(:project) } - set(:pipeline) { create(:ci_pipeline, project: project) } + let_it_be(:user) { create(:user) } + let_it_be(:project) { create(:project) } + let_it_be(:pipeline) { create(:ci_pipeline, project: project) } subject { described_class.new } describe '#execute' do diff --git a/spec/services/ci/pipeline_processing/atomic_processing_service/status_collection_spec.rb b/spec/services/ci/pipeline_processing/atomic_processing_service/status_collection_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..c29c56c2b045ee059d30b9d4907bb3beeba73217 --- /dev/null +++ b/spec/services/ci/pipeline_processing/atomic_processing_service/status_collection_spec.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Ci::PipelineProcessing::AtomicProcessingService::StatusCollection do + using RSpec::Parameterized::TableSyntax + + set(:pipeline) { create(:ci_pipeline) } + set(:build_a) { create(:ci_build, :success, name: 'build-a', stage: 'build', stage_idx: 0, pipeline: pipeline) } + set(:build_b) { create(:ci_build, :failed, name: 'build-b', stage: 'build', stage_idx: 0, pipeline: pipeline) } + set(:test_a) { create(:ci_build, :running, name: 'test-a', stage: 'test', stage_idx: 1, pipeline: pipeline) } + set(:test_b) { create(:ci_build, :pending, name: 'test-b', stage: 'test', stage_idx: 1, pipeline: pipeline) } + set(:deploy) { create(:ci_build, :created, name: 'deploy', stage: 'deploy', stage_idx: 2, pipeline: pipeline) } + + let(:collection) { described_class.new(pipeline) } + + describe '#set_processable_status' do + it 'does update existing status of processable' do + collection.set_processable_status(test_a.id, 'success', 100) + + expect(collection.status_for_names(['test-a'])).to eq('success') + end + + it 'ignores a missing processable' do + collection.set_processable_status(-1, 'failed', 100) + end + end + + describe '#status_of_all' do + it 'returns composite status of the collection' do + expect(collection.status_of_all).to eq('running') + end + end + + describe '#status_for_names' do + where(:names, :status) do + %w[build-a] | 'success' + %w[build-a build-b] | 'failed' + %w[build-a test-a] | 'running' + end + + with_them do + it 'returns composite status of given names' do + expect(collection.status_for_names(names)).to eq(status) + end + end + end + + describe '#status_for_prior_stage_position' do + where(:stage, :status) do + 0 | 'success' + 1 | 'failed' + 2 | 'running' + end + + with_them do + it 'returns composite status for processables in prior stages' do + expect(collection.status_for_prior_stage_position(stage)).to eq(status) + end + end + end + + describe '#status_for_stage_position' do + where(:stage, :status) do + 0 | 'failed' + 1 | 'running' + 2 | 'created' + end + + with_them do + it 'returns composite status for processables at a given stages' do + expect(collection.status_for_stage_position(stage)).to eq(status) + end + end + end + + describe '#created_processable_ids_for_stage_position' do + it 'returns IDs of processables at a given stage position' do + expect(collection.created_processable_ids_for_stage_position(0)).to be_empty + expect(collection.created_processable_ids_for_stage_position(1)).to be_empty + expect(collection.created_processable_ids_for_stage_position(2)).to contain_exactly(deploy.id) + end + end + + describe '#processing_processables' do + it 'returns processables marked as processing' do + expect(collection.processing_processables.map { |processable| processable[:id]} ) + .to contain_exactly(build_a.id, build_b.id, test_a.id, test_b.id, deploy.id) + end + end +end diff --git a/spec/services/ci/pipeline_processing/atomic_processing_service_spec.rb b/spec/services/ci/pipeline_processing/atomic_processing_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..38686b41a228885d27eb1593a8752dc386c29087 --- /dev/null +++ b/spec/services/ci/pipeline_processing/atomic_processing_service_spec.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative 'shared_processing_service.rb' + +describe Ci::PipelineProcessing::AtomicProcessingService do + before do + stub_feature_flags(ci_atomic_processing: true) + end + + it_behaves_like 'Pipeline Processing Service' +end diff --git a/spec/services/ci/pipeline_processing/legacy_processing_service_spec.rb b/spec/services/ci/pipeline_processing/legacy_processing_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..2da1eb1981825ac46cbed9ab254dde508bc82817 --- /dev/null +++ b/spec/services/ci/pipeline_processing/legacy_processing_service_spec.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative 'shared_processing_service.rb' + +describe Ci::PipelineProcessing::LegacyProcessingService do + before do + stub_feature_flags(ci_atomic_processing: false) + end + + it_behaves_like 'Pipeline Processing Service' +end diff --git a/spec/services/ci/pipeline_processing/shared_processing_service.rb b/spec/services/ci/pipeline_processing/shared_processing_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..cae5ae3f09d00c7ace7abe1af04654cd94130c1c --- /dev/null +++ b/spec/services/ci/pipeline_processing/shared_processing_service.rb @@ -0,0 +1,940 @@ +# frozen_string_literal: true + +shared_examples 'Pipeline Processing Service' do + let(:user) { create(:user) } + let(:project) { create(:project) } + + let(:pipeline) do + create(:ci_empty_pipeline, ref: 'master', project: project) + end + + before do + stub_ci_pipeline_to_return_yaml_file + + stub_not_protect_default_branch + + project.add_developer(user) + end + + context 'when simple pipeline is defined' do + before do + create_build('linux', stage_idx: 0) + create_build('mac', stage_idx: 0) + create_build('rspec', stage_idx: 1) + create_build('rubocop', stage_idx: 1) + create_build('deploy', stage_idx: 2) + end + + it 'processes a pipeline', :sidekiq_inline do + expect(process_pipeline).to be_truthy + + succeed_pending + + expect(builds.success.count).to eq(2) + + succeed_pending + + expect(builds.success.count).to eq(4) + + succeed_pending + + expect(builds.success.count).to eq(5) + end + + it 'does not process pipeline if existing stage is running' do + expect(process_pipeline).to be_truthy + expect(builds.pending.count).to eq(2) + + expect(process_pipeline).to be_falsey + expect(builds.pending.count).to eq(2) + end + end + + context 'custom stage with first job allowed to fail' do + before do + create_build('clean_job', stage_idx: 0, allow_failure: true) + create_build('test_job', stage_idx: 1, allow_failure: true) + end + + it 'automatically triggers a next stage when build finishes', :sidekiq_inline do + expect(process_pipeline).to be_truthy + expect(builds_statuses).to eq ['pending'] + + fail_running_or_pending + + expect(builds_statuses).to eq %w(failed pending) + + fail_running_or_pending + + expect(pipeline.reload).to be_success + end + end + + context 'when optional manual actions are defined', :sidekiq_inline do + before do + create_build('build', stage_idx: 0) + create_build('test', stage_idx: 1) + create_build('test_failure', stage_idx: 2, when: 'on_failure') + create_build('deploy', stage_idx: 3) + create_build('production', stage_idx: 3, when: 'manual', allow_failure: true) + create_build('cleanup', stage_idx: 4, when: 'always') + create_build('clear:cache', stage_idx: 4, when: 'manual', allow_failure: true) + end + + context 'when builds are successful' do + it 'properly processes the pipeline' do + expect(process_pipeline).to be_truthy + expect(builds_names).to eq ['build'] + expect(builds_statuses).to eq ['pending'] + + succeed_running_or_pending + + expect(builds_names).to eq %w(build test) + expect(builds_statuses).to eq %w(success pending) + + succeed_running_or_pending + + expect(builds_names).to eq %w(build test deploy production) + expect(builds_statuses).to eq %w(success success pending manual) + + succeed_running_or_pending + + expect(builds_names).to eq %w(build test deploy production cleanup clear:cache) + expect(builds_statuses).to eq %w(success success success manual pending manual) + + succeed_running_or_pending + + expect(builds_statuses).to eq %w(success success success manual success manual) + expect(pipeline.reload.status).to eq 'success' + end + end + + context 'when test job fails' do + it 'properly processes the pipeline' do + expect(process_pipeline).to be_truthy + expect(builds_names).to eq ['build'] + expect(builds_statuses).to eq ['pending'] + + succeed_running_or_pending + + expect(builds_names).to eq %w(build test) + expect(builds_statuses).to eq %w(success pending) + + fail_running_or_pending + + expect(builds_names).to eq %w(build test test_failure) + expect(builds_statuses).to eq %w(success failed pending) + + succeed_running_or_pending + + expect(builds_names).to eq %w(build test test_failure cleanup) + expect(builds_statuses).to eq %w(success failed success pending) + + succeed_running_or_pending + + expect(builds_statuses).to eq %w(success failed success success) + expect(pipeline.reload.status).to eq 'failed' + end + end + + context 'when test and test_failure jobs fail' do + it 'properly processes the pipeline' do + expect(process_pipeline).to be_truthy + expect(builds_names).to eq ['build'] + expect(builds_statuses).to eq ['pending'] + + succeed_running_or_pending + + expect(builds_names).to eq %w(build test) + expect(builds_statuses).to eq %w(success pending) + + fail_running_or_pending + + expect(builds_names).to eq %w(build test test_failure) + expect(builds_statuses).to eq %w(success failed pending) + + fail_running_or_pending + + expect(builds_names).to eq %w(build test test_failure cleanup) + expect(builds_statuses).to eq %w(success failed failed pending) + + succeed_running_or_pending + + expect(builds_names).to eq %w(build test test_failure cleanup) + expect(builds_statuses).to eq %w(success failed failed success) + expect(pipeline.reload.status).to eq('failed') + end + end + + context 'when deploy job fails' do + it 'properly processes the pipeline' do + expect(process_pipeline).to be_truthy + expect(builds_names).to eq ['build'] + expect(builds_statuses).to eq ['pending'] + + succeed_running_or_pending + + expect(builds_names).to eq %w(build test) + expect(builds_statuses).to eq %w(success pending) + + succeed_running_or_pending + + expect(builds_names).to eq %w(build test deploy production) + expect(builds_statuses).to eq %w(success success pending manual) + + fail_running_or_pending + + expect(builds_names).to eq %w(build test deploy production cleanup) + expect(builds_statuses).to eq %w(success success failed manual pending) + + succeed_running_or_pending + + expect(builds_statuses).to eq %w(success success failed manual success) + expect(pipeline.reload).to be_failed + end + end + + context 'when build is canceled in the second stage' do + it 'does not schedule builds after build has been canceled' do + expect(process_pipeline).to be_truthy + expect(builds_names).to eq ['build'] + expect(builds_statuses).to eq ['pending'] + + succeed_running_or_pending + + expect(builds.running_or_pending).not_to be_empty + expect(builds_names).to eq %w(build test) + expect(builds_statuses).to eq %w(success pending) + + cancel_running_or_pending + + expect(builds.running_or_pending).to be_empty + expect(builds_names).to eq %w[build test] + expect(builds_statuses).to eq %w[success canceled] + expect(pipeline.reload).to be_canceled + end + end + + context 'when listing optional manual actions' do + it 'returns only for skipped builds' do + # currently all builds are created + expect(process_pipeline).to be_truthy + expect(manual_actions).to be_empty + + # succeed stage build + succeed_running_or_pending + + expect(manual_actions).to be_empty + + # succeed stage test + succeed_running_or_pending + + expect(manual_actions).to be_one # production + + # succeed stage deploy + succeed_running_or_pending + + expect(manual_actions).to be_many # production and clear cache + end + end + end + + context 'when delayed jobs are defined', :sidekiq_inline do + context 'when the scene is timed incremental rollout' do + before do + create_build('build', stage_idx: 0) + create_build('rollout10%', **delayed_options, stage_idx: 1) + create_build('rollout100%', **delayed_options, stage_idx: 2) + create_build('cleanup', stage_idx: 3) + + allow(Ci::BuildScheduleWorker).to receive(:perform_at) + end + + context 'when builds are successful' do + it 'properly processes the pipeline' do + expect(process_pipeline).to be_truthy + expect(builds_names_and_statuses).to eq({ 'build': 'pending' }) + + succeed_pending + + expect(builds_names_and_statuses).to eq({ 'build': 'success', 'rollout10%': 'scheduled' }) + + Timecop.travel 2.minutes.from_now do + enqueue_scheduled('rollout10%') + end + succeed_pending + + expect(builds_names_and_statuses).to eq({ 'build': 'success', 'rollout10%': 'success', 'rollout100%': 'scheduled' }) + + Timecop.travel 2.minutes.from_now do + enqueue_scheduled('rollout100%') + end + succeed_pending + + expect(builds_names_and_statuses).to eq({ 'build': 'success', 'rollout10%': 'success', 'rollout100%': 'success', 'cleanup': 'pending' }) + + succeed_pending + + expect(builds_names_and_statuses).to eq({ 'build': 'success', 'rollout10%': 'success', 'rollout100%': 'success', 'cleanup': 'success' }) + expect(pipeline.reload.status).to eq 'success' + end + end + + context 'when build job fails' do + it 'properly processes the pipeline' do + expect(process_pipeline).to be_truthy + expect(builds_names_and_statuses).to eq({ 'build': 'pending' }) + + fail_running_or_pending + + expect(builds_names_and_statuses).to eq({ 'build': 'failed' }) + expect(pipeline.reload.status).to eq 'failed' + end + end + + context 'when rollout 10% is unscheduled' do + it 'properly processes the pipeline' do + expect(process_pipeline).to be_truthy + expect(builds_names_and_statuses).to eq({ 'build': 'pending' }) + + succeed_pending + + expect(builds_names_and_statuses).to eq({ 'build': 'success', 'rollout10%': 'scheduled' }) + + unschedule + + expect(builds_names_and_statuses).to eq({ 'build': 'success', 'rollout10%': 'manual' }) + expect(pipeline.reload.status).to eq 'manual' + end + + context 'when user plays rollout 10%' do + it 'schedules rollout100%' do + process_pipeline + succeed_pending + unschedule + play_manual_action('rollout10%') + succeed_pending + + expect(builds_names_and_statuses).to eq({ 'build': 'success', 'rollout10%': 'success', 'rollout100%': 'scheduled' }) + expect(pipeline.reload.status).to eq 'scheduled' + end + end + end + + context 'when rollout 10% fails' do + it 'properly processes the pipeline' do + expect(process_pipeline).to be_truthy + expect(builds_names_and_statuses).to eq({ 'build': 'pending' }) + + succeed_pending + + expect(builds_names_and_statuses).to eq({ 'build': 'success', 'rollout10%': 'scheduled' }) + + Timecop.travel 2.minutes.from_now do + enqueue_scheduled('rollout10%') + end + fail_running_or_pending + + expect(builds_names_and_statuses).to eq({ 'build': 'success', 'rollout10%': 'failed' }) + expect(pipeline.reload.status).to eq 'failed' + end + + context 'when user retries rollout 10%' do + it 'does not schedule rollout10% again' do + process_pipeline + succeed_pending + enqueue_scheduled('rollout10%') + fail_running_or_pending + retry_build('rollout10%') + + expect(builds_names_and_statuses).to eq({ 'build': 'success', 'rollout10%': 'pending' }) + expect(pipeline.reload.status).to eq 'running' + end + end + end + + context 'when rollout 10% is played immidiately' do + it 'properly processes the pipeline' do + expect(process_pipeline).to be_truthy + expect(builds_names_and_statuses).to eq({ 'build': 'pending' }) + + succeed_pending + + expect(builds_names_and_statuses).to eq({ 'build': 'success', 'rollout10%': 'scheduled' }) + + play_manual_action('rollout10%') + + expect(builds_names_and_statuses).to eq({ 'build': 'success', 'rollout10%': 'pending' }) + expect(pipeline.reload.status).to eq 'running' + end + end + end + + context 'when only one scheduled job exists in a pipeline' do + before do + create_build('delayed', **delayed_options, stage_idx: 0) + + allow(Ci::BuildScheduleWorker).to receive(:perform_at) + end + + it 'properly processes the pipeline' do + expect(process_pipeline).to be_truthy + expect(builds_names_and_statuses).to eq({ 'delayed': 'scheduled' }) + + expect(pipeline.reload.status).to eq 'scheduled' + end + end + + context 'when there are two delayed jobs in a stage' do + before do + create_build('delayed1', **delayed_options, stage_idx: 0) + create_build('delayed2', **delayed_options, stage_idx: 0) + create_build('job', stage_idx: 1) + + allow(Ci::BuildScheduleWorker).to receive(:perform_at) + end + + it 'blocks the stage until all scheduled jobs finished' do + expect(process_pipeline).to be_truthy + expect(builds_names_and_statuses).to eq({ 'delayed1': 'scheduled', 'delayed2': 'scheduled' }) + + Timecop.travel 2.minutes.from_now do + enqueue_scheduled('delayed1') + end + + expect(builds_names_and_statuses).to eq({ 'delayed1': 'pending', 'delayed2': 'scheduled' }) + expect(pipeline.reload.status).to eq 'running' + end + end + + context 'when a delayed job is allowed to fail' do + before do + create_build('delayed', **delayed_options, allow_failure: true, stage_idx: 0) + create_build('job', stage_idx: 1) + + allow(Ci::BuildScheduleWorker).to receive(:perform_at) + end + + it 'blocks the stage and continues after it failed' do + expect(process_pipeline).to be_truthy + expect(builds_names_and_statuses).to eq({ 'delayed': 'scheduled' }) + + Timecop.travel 2.minutes.from_now do + enqueue_scheduled('delayed') + end + fail_running_or_pending + + expect(builds_names_and_statuses).to eq({ 'delayed': 'failed', 'job': 'pending' }) + expect(pipeline.reload.status).to eq 'pending' + end + end + end + + context 'when an exception is raised during a persistent ref creation' do + before do + successful_build('test', stage_idx: 0) + + allow_next_instance_of(Ci::PersistentRef) do |instance| + allow(instance).to receive(:delete_refs) { raise ArgumentError } + end + end + + it 'process the pipeline' do + expect { process_pipeline }.not_to raise_error + end + end + + context 'when there are manual action in earlier stages' do + context 'when first stage has only optional manual actions' do + before do + create_build('build', stage_idx: 0, when: 'manual', allow_failure: true) + create_build('check', stage_idx: 1) + create_build('test', stage_idx: 2) + + process_pipeline + end + + it 'starts from the second stage' do + expect(all_builds_statuses).to eq %w[manual pending created] + end + end + + context 'when second stage has only optional manual actions' do + before do + create_build('check', stage_idx: 0) + create_build('build', stage_idx: 1, when: 'manual', allow_failure: true) + create_build('test', stage_idx: 2) + + process_pipeline + end + + it 'skips second stage and continues on third stage', :sidekiq_inline do + expect(all_builds_statuses).to eq(%w[pending created created]) + + builds.first.success + + expect(all_builds_statuses).to eq(%w[success manual pending]) + end + end + end + + context 'when there are only manual actions in stages' do + before do + create_build('image', stage_idx: 0, when: 'manual', allow_failure: true) + create_build('build', stage_idx: 1, when: 'manual', allow_failure: true) + create_build('deploy', stage_idx: 2, when: 'manual') + create_build('check', stage_idx: 3) + + process_pipeline + end + + it 'processes all jobs until blocking actions encountered' do + expect(all_builds_statuses).to eq(%w[manual manual manual created]) + expect(all_builds_names).to eq(%w[image build deploy check]) + + expect(pipeline.reload).to be_blocked + end + end + + context 'when there is only one manual action' do + before do + create_build('deploy', stage_idx: 0, when: 'manual', allow_failure: true) + + process_pipeline + end + + it 'skips the pipeline' do + expect(pipeline.reload).to be_skipped + end + + context 'when the action was played' do + before do + play_manual_action('deploy') + end + + it 'queues the action and pipeline', :sidekiq_inline do + expect(all_builds_statuses).to eq(%w[pending]) + + expect(pipeline.reload).to be_pending + end + end + end + + context 'when blocking manual actions are defined', :sidekiq_inline do + before do + create_build('code:test', stage_idx: 0) + create_build('staging:deploy', stage_idx: 1, when: 'manual') + create_build('staging:test', stage_idx: 2, when: 'on_success') + create_build('production:deploy', stage_idx: 3, when: 'manual') + create_build('production:test', stage_idx: 4, when: 'always') + end + + context 'when first stage succeeds' do + it 'blocks pipeline on stage with first manual action' do + process_pipeline + + expect(builds_names).to eq %w[code:test] + expect(builds_statuses).to eq %w[pending] + expect(pipeline.reload.status).to eq 'pending' + + succeed_running_or_pending + + expect(builds_names).to eq %w[code:test staging:deploy] + expect(builds_statuses).to eq %w[success manual] + expect(pipeline.reload).to be_manual + end + end + + context 'when first stage fails' do + it 'does not take blocking action into account' do + process_pipeline + + expect(builds_names).to eq %w[code:test] + expect(builds_statuses).to eq %w[pending] + expect(pipeline.reload.status).to eq 'pending' + + fail_running_or_pending + + expect(builds_names).to eq %w[code:test production:test] + expect(builds_statuses).to eq %w[failed pending] + + succeed_running_or_pending + + expect(builds_statuses).to eq %w[failed success] + expect(pipeline.reload).to be_failed + end + end + + context 'when pipeline is promoted sequentially up to the end' do + before do + # Users need ability to merge into a branch in order to trigger + # protected manual actions. + # + create(:protected_branch, :developers_can_merge, + name: 'master', project: project) + end + + it 'properly processes entire pipeline' do + process_pipeline + + expect(builds_names).to eq %w[code:test] + expect(builds_statuses).to eq %w[pending] + + succeed_running_or_pending + + expect(builds_names).to eq %w[code:test staging:deploy] + expect(builds_statuses).to eq %w[success manual] + expect(pipeline.reload).to be_manual + + play_manual_action('staging:deploy') + + expect(builds_statuses).to eq %w[success pending] + + succeed_running_or_pending + + expect(builds_names).to eq %w[code:test staging:deploy staging:test] + expect(builds_statuses).to eq %w[success success pending] + + succeed_running_or_pending + + expect(builds_names).to eq %w[code:test staging:deploy staging:test + production:deploy] + expect(builds_statuses).to eq %w[success success success manual] + + expect(pipeline.reload).to be_manual + expect(pipeline.reload).to be_blocked + expect(pipeline.reload).not_to be_active + expect(pipeline.reload).not_to be_complete + + play_manual_action('production:deploy') + + expect(builds_statuses).to eq %w[success success success pending] + expect(pipeline.reload).to be_running + + succeed_running_or_pending + + expect(builds_names).to eq %w[code:test staging:deploy staging:test + production:deploy production:test] + expect(builds_statuses).to eq %w[success success success success pending] + expect(pipeline.reload).to be_running + + succeed_running_or_pending + + expect(builds_names).to eq %w[code:test staging:deploy staging:test + production:deploy production:test] + expect(builds_statuses).to eq %w[success success success success success] + expect(pipeline.reload).to be_success + end + end + end + + context 'when second stage has only on_failure jobs', :sidekiq_inline do + before do + create_build('check', stage_idx: 0) + create_build('build', stage_idx: 1, when: 'on_failure') + create_build('test', stage_idx: 2) + + process_pipeline + end + + it 'skips second stage and continues on third stage' do + expect(all_builds_statuses).to eq(%w[pending created created]) + + builds.first.success + + expect(all_builds_statuses).to eq(%w[success skipped pending]) + end + end + + context 'when failed build in the middle stage is retried', :sidekiq_inline do + context 'when failed build is the only unsuccessful build in the stage' do + before do + create_build('build:1', stage_idx: 0) + create_build('build:2', stage_idx: 0) + create_build('test:1', stage_idx: 1) + create_build('test:2', stage_idx: 1) + create_build('deploy:1', stage_idx: 2) + create_build('deploy:2', stage_idx: 2) + end + + it 'does trigger builds in the next stage' do + expect(process_pipeline).to be_truthy + expect(builds_names).to eq ['build:1', 'build:2'] + + succeed_running_or_pending + + expect(builds_names).to eq ['build:1', 'build:2', 'test:1', 'test:2'] + + pipeline.builds.find_by(name: 'test:1').success! + pipeline.builds.find_by(name: 'test:2').drop! + + expect(builds_names).to eq ['build:1', 'build:2', 'test:1', 'test:2'] + + Ci::Build.retry(pipeline.builds.find_by(name: 'test:2'), user).reset.success! + + expect(builds_names).to eq ['build:1', 'build:2', 'test:1', 'test:2', + 'test:2', 'deploy:1', 'deploy:2'] + end + end + end + + context 'when builds with auto-retries are configured', :sidekiq_inline do + before do + create_build('build:1', stage_idx: 0, user: user, options: { script: 'aa', retry: 2 }) + create_build('test:1', stage_idx: 1, user: user, when: :on_failure) + create_build('test:2', stage_idx: 1, user: user, options: { script: 'aa', retry: 1 }) + end + + it 'automatically retries builds in a valid order' do + expect(process_pipeline).to be_truthy + + fail_running_or_pending + + expect(builds_names).to eq %w[build:1 build:1] + expect(builds_statuses).to eq %w[failed pending] + + succeed_running_or_pending + + expect(builds_names).to eq %w[build:1 build:1 test:2] + expect(builds_statuses).to eq %w[failed success pending] + + succeed_running_or_pending + + expect(builds_names).to eq %w[build:1 build:1 test:2] + expect(builds_statuses).to eq %w[failed success success] + + expect(pipeline.reload).to be_success + end + end + + context 'when pipeline with needs is created', :sidekiq_inline do + let!(:linux_build) { create_build('linux:build', stage: 'build', stage_idx: 0) } + let!(:mac_build) { create_build('mac:build', stage: 'build', stage_idx: 0) } + let!(:linux_rspec) { create_build('linux:rspec', stage: 'test', stage_idx: 1) } + let!(:linux_rubocop) { create_build('linux:rubocop', stage: 'test', stage_idx: 1) } + let!(:mac_rspec) { create_build('mac:rspec', stage: 'test', stage_idx: 1) } + let!(:mac_rubocop) { create_build('mac:rubocop', stage: 'test', stage_idx: 1) } + let!(:deploy) { create_build('deploy', stage: 'deploy', stage_idx: 2) } + + let!(:linux_rspec_on_build) { create(:ci_build_need, build: linux_rspec, name: 'linux:build') } + let!(:linux_rubocop_on_build) { create(:ci_build_need, build: linux_rubocop, name: 'linux:build') } + + let!(:mac_rspec_on_build) { create(:ci_build_need, build: mac_rspec, name: 'mac:build') } + let!(:mac_rubocop_on_build) { create(:ci_build_need, build: mac_rubocop, name: 'mac:build') } + + it 'when linux:* finishes first it runs it out of order' do + expect(process_pipeline).to be_truthy + + expect(stages).to eq(%w(pending created created)) + expect(builds.pending).to contain_exactly(linux_build, mac_build) + + # we follow the single path of linux + linux_build.reset.success! + + expect(stages).to eq(%w(running pending created)) + expect(builds.success).to contain_exactly(linux_build) + expect(builds.pending).to contain_exactly(mac_build, linux_rspec, linux_rubocop) + + linux_rspec.reset.success! + + expect(stages).to eq(%w(running running created)) + expect(builds.success).to contain_exactly(linux_build, linux_rspec) + expect(builds.pending).to contain_exactly(mac_build, linux_rubocop) + + linux_rubocop.reset.success! + + expect(stages).to eq(%w(running running created)) + expect(builds.success).to contain_exactly(linux_build, linux_rspec, linux_rubocop) + expect(builds.pending).to contain_exactly(mac_build) + + mac_build.reset.success! + mac_rspec.reset.success! + mac_rubocop.reset.success! + + expect(stages).to eq(%w(success success pending)) + expect(builds.success).to contain_exactly( + linux_build, linux_rspec, linux_rubocop, mac_build, mac_rspec, mac_rubocop) + expect(builds.pending).to contain_exactly(deploy) + end + + context 'when feature ci_dag_support is disabled' do + before do + stub_feature_flags(ci_dag_support: false) + end + + it 'when linux:build finishes first it follows stages' do + expect(process_pipeline).to be_truthy + + expect(stages).to eq(%w(pending created created)) + expect(builds.pending).to contain_exactly(linux_build, mac_build) + + # we follow the single path of linux + linux_build.reset.success! + + expect(stages).to eq(%w(running created created)) + expect(builds.success).to contain_exactly(linux_build) + expect(builds.pending).to contain_exactly(mac_build) + + mac_build.reset.success! + + expect(stages).to eq(%w(success pending created)) + expect(builds.success).to contain_exactly(linux_build, mac_build) + expect(builds.pending).to contain_exactly( + linux_rspec, linux_rubocop, mac_rspec, mac_rubocop) + + linux_rspec.reset.success! + linux_rubocop.reset.success! + mac_rspec.reset.success! + mac_rubocop.reset.success! + + expect(stages).to eq(%w(success success pending)) + expect(builds.success).to contain_exactly( + linux_build, linux_rspec, linux_rubocop, mac_build, mac_rspec, mac_rubocop) + expect(builds.pending).to contain_exactly(deploy) + end + end + + context 'when one of the jobs is run on a failure' do + let!(:linux_notify) { create_build('linux:notify', stage: 'deploy', stage_idx: 2, when: 'on_failure') } + + let!(:linux_notify_on_build) { create(:ci_build_need, build: linux_notify, name: 'linux:build') } + + context 'when another job in build phase fails first' do + context 'when ci_dag_support is enabled' do + it 'does skip linux:notify' do + expect(process_pipeline).to be_truthy + + mac_build.reset.drop! + linux_build.reset.success! + + expect(linux_notify.reset).to be_skipped + end + end + + context 'when ci_dag_support is disabled' do + before do + stub_feature_flags(ci_dag_support: false) + end + + it 'does run linux:notify' do + expect(process_pipeline).to be_truthy + + mac_build.reset.drop! + linux_build.reset.success! + + expect(linux_notify.reset).to be_pending + end + end + end + + context 'when linux:build job fails first' do + it 'does run linux:notify' do + expect(process_pipeline).to be_truthy + + linux_build.reset.drop! + + expect(linux_notify.reset).to be_pending + end + end + end + end + + def process_pipeline + described_class.new(pipeline).execute + end + + def all_builds + pipeline.builds.order(:stage_idx, :id) + end + + def builds + all_builds.where.not(status: [:created, :skipped]) + end + + def stages + pipeline.reset.stages.map(&:status) + end + + def builds_names + builds.pluck(:name) + end + + def builds_names_and_statuses + builds.each_with_object({}) do |b, h| + h[b.name.to_sym] = b.status + h + end + end + + def all_builds_names + all_builds.pluck(:name) + end + + def builds_statuses + builds.pluck(:status) + end + + def all_builds_statuses + all_builds.pluck(:status) + end + + def succeed_pending + builds.pending.each do |build| + build.reset.success + end + end + + def succeed_running_or_pending + pipeline.builds.running_or_pending.each do |build| + build.reset.success + end + end + + def fail_running_or_pending + pipeline.builds.running_or_pending.each do |build| + build.reset.drop + end + end + + def cancel_running_or_pending + pipeline.builds.running_or_pending.each do |build| + build.reset.cancel + end + end + + def play_manual_action(name) + builds.find_by(name: name).play(user) + end + + def enqueue_scheduled(name) + builds.scheduled.find_by(name: name).enqueue_scheduled + end + + def retry_build(name) + Ci::Build.retry(builds.find_by(name: name), user) + end + + def manual_actions + pipeline.manual_actions.reload + end + + def create_build(name, **opts) + create(:ci_build, :created, pipeline: pipeline, name: name, **with_stage_opts(opts)) + end + + def successful_build(name, **opts) + create(:ci_build, :success, pipeline: pipeline, name: name, **with_stage_opts(opts)) + end + + def with_stage_opts(opts) + { stage: "stage-#{opts[:stage_idx].to_i}" }.merge(opts) + end + + def delayed_options + { when: 'delayed', options: { script: %w(echo), start_in: '1 minute' } } + end + + def unschedule + pipeline.builds.scheduled.map(&:unschedule) + end +end diff --git a/spec/services/ci/prepare_build_service_spec.rb b/spec/services/ci/prepare_build_service_spec.rb index 3c3d8b90bb03058565fee8b20636c7cce99695a6..02928b58ff8b3f69d5156c628ddaff034ec339d9 100644 --- a/spec/services/ci/prepare_build_service_spec.rb +++ b/spec/services/ci/prepare_build_service_spec.rb @@ -14,7 +14,7 @@ describe Ci::PrepareBuildService do shared_examples 'build enqueueing' do it 'enqueues the build' do - expect(build).to receive(:enqueue).once + expect(build).to receive(:enqueue_preparing).once subject end @@ -34,7 +34,7 @@ describe Ci::PrepareBuildService do context 'prerequisites fail to complete' do before do - allow(build).to receive(:enqueue).and_return(false) + allow(build).to receive(:enqueue_preparing).and_return(false) end it 'drops the build' do diff --git a/spec/services/ci/process_pipeline_service_spec.rb b/spec/services/ci/process_pipeline_service_spec.rb index ba5891c86949dd69549f203425e78749a0a8b692..40ae1c4029b36241bc7376868fa62456dbe19a92 100644 --- a/spec/services/ci/process_pipeline_service_spec.rb +++ b/spec/services/ci/process_pipeline_service_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -describe Ci::ProcessPipelineService, '#execute' do +describe Ci::ProcessPipelineService do let(:user) { create(:user) } let(:project) { create(:project) } @@ -18,658 +18,6 @@ describe Ci::ProcessPipelineService, '#execute' do project.add_developer(user) end - context 'when simple pipeline is defined' do - before do - create_build('linux', stage_idx: 0) - create_build('mac', stage_idx: 0) - create_build('rspec', stage_idx: 1) - create_build('rubocop', stage_idx: 1) - create_build('deploy', stage_idx: 2) - end - - it 'processes a pipeline', :sidekiq_might_not_need_inline do - expect(process_pipeline).to be_truthy - - succeed_pending - - expect(builds.success.count).to eq(2) - - succeed_pending - - expect(builds.success.count).to eq(4) - - succeed_pending - - expect(builds.success.count).to eq(5) - end - - it 'does not process pipeline if existing stage is running' do - expect(process_pipeline).to be_truthy - expect(builds.pending.count).to eq(2) - - expect(process_pipeline).to be_falsey - expect(builds.pending.count).to eq(2) - end - end - - context 'custom stage with first job allowed to fail' do - before do - create_build('clean_job', stage_idx: 0, allow_failure: true) - create_build('test_job', stage_idx: 1, allow_failure: true) - end - - it 'automatically triggers a next stage when build finishes', :sidekiq_might_not_need_inline do - expect(process_pipeline).to be_truthy - expect(builds_statuses).to eq ['pending'] - - fail_running_or_pending - - expect(builds_statuses).to eq %w(failed pending) - - fail_running_or_pending - - expect(pipeline.reload).to be_success - end - end - - context 'when optional manual actions are defined', :sidekiq_might_not_need_inline do - before do - create_build('build', stage_idx: 0) - create_build('test', stage_idx: 1) - create_build('test_failure', stage_idx: 2, when: 'on_failure') - create_build('deploy', stage_idx: 3) - create_build('production', stage_idx: 3, when: 'manual', allow_failure: true) - create_build('cleanup', stage_idx: 4, when: 'always') - create_build('clear:cache', stage_idx: 4, when: 'manual', allow_failure: true) - end - - context 'when builds are successful' do - it 'properly processes the pipeline' do - expect(process_pipeline).to be_truthy - expect(builds_names).to eq ['build'] - expect(builds_statuses).to eq ['pending'] - - succeed_running_or_pending - - expect(builds_names).to eq %w(build test) - expect(builds_statuses).to eq %w(success pending) - - succeed_running_or_pending - - expect(builds_names).to eq %w(build test deploy production) - expect(builds_statuses).to eq %w(success success pending manual) - - succeed_running_or_pending - - expect(builds_names).to eq %w(build test deploy production cleanup clear:cache) - expect(builds_statuses).to eq %w(success success success manual pending manual) - - succeed_running_or_pending - - expect(builds_statuses).to eq %w(success success success manual success manual) - expect(pipeline.reload.status).to eq 'success' - end - end - - context 'when test job fails' do - it 'properly processes the pipeline' do - expect(process_pipeline).to be_truthy - expect(builds_names).to eq ['build'] - expect(builds_statuses).to eq ['pending'] - - succeed_running_or_pending - - expect(builds_names).to eq %w(build test) - expect(builds_statuses).to eq %w(success pending) - - fail_running_or_pending - - expect(builds_names).to eq %w(build test test_failure) - expect(builds_statuses).to eq %w(success failed pending) - - succeed_running_or_pending - - expect(builds_names).to eq %w(build test test_failure cleanup) - expect(builds_statuses).to eq %w(success failed success pending) - - succeed_running_or_pending - - expect(builds_statuses).to eq %w(success failed success success) - expect(pipeline.reload.status).to eq 'failed' - end - end - - context 'when test and test_failure jobs fail' do - it 'properly processes the pipeline' do - expect(process_pipeline).to be_truthy - expect(builds_names).to eq ['build'] - expect(builds_statuses).to eq ['pending'] - - succeed_running_or_pending - - expect(builds_names).to eq %w(build test) - expect(builds_statuses).to eq %w(success pending) - - fail_running_or_pending - - expect(builds_names).to eq %w(build test test_failure) - expect(builds_statuses).to eq %w(success failed pending) - - fail_running_or_pending - - expect(builds_names).to eq %w(build test test_failure cleanup) - expect(builds_statuses).to eq %w(success failed failed pending) - - succeed_running_or_pending - - expect(builds_names).to eq %w(build test test_failure cleanup) - expect(builds_statuses).to eq %w(success failed failed success) - expect(pipeline.reload.status).to eq('failed') - end - end - - context 'when deploy job fails' do - it 'properly processes the pipeline' do - expect(process_pipeline).to be_truthy - expect(builds_names).to eq ['build'] - expect(builds_statuses).to eq ['pending'] - - succeed_running_or_pending - - expect(builds_names).to eq %w(build test) - expect(builds_statuses).to eq %w(success pending) - - succeed_running_or_pending - - expect(builds_names).to eq %w(build test deploy production) - expect(builds_statuses).to eq %w(success success pending manual) - - fail_running_or_pending - - expect(builds_names).to eq %w(build test deploy production cleanup) - expect(builds_statuses).to eq %w(success success failed manual pending) - - succeed_running_or_pending - - expect(builds_statuses).to eq %w(success success failed manual success) - expect(pipeline.reload).to be_failed - end - end - - context 'when build is canceled in the second stage' do - it 'does not schedule builds after build has been canceled' do - expect(process_pipeline).to be_truthy - expect(builds_names).to eq ['build'] - expect(builds_statuses).to eq ['pending'] - - succeed_running_or_pending - - expect(builds.running_or_pending).not_to be_empty - expect(builds_names).to eq %w(build test) - expect(builds_statuses).to eq %w(success pending) - - cancel_running_or_pending - - expect(builds.running_or_pending).to be_empty - expect(builds_names).to eq %w[build test] - expect(builds_statuses).to eq %w[success canceled] - expect(pipeline.reload).to be_canceled - end - end - - context 'when listing optional manual actions' do - it 'returns only for skipped builds' do - # currently all builds are created - expect(process_pipeline).to be_truthy - expect(manual_actions).to be_empty - - # succeed stage build - succeed_running_or_pending - - expect(manual_actions).to be_empty - - # succeed stage test - succeed_running_or_pending - - expect(manual_actions).to be_one # production - - # succeed stage deploy - succeed_running_or_pending - - expect(manual_actions).to be_many # production and clear cache - end - end - end - - context 'when delayed jobs are defined', :sidekiq_might_not_need_inline do - context 'when the scene is timed incremental rollout' do - before do - create_build('build', stage_idx: 0) - create_build('rollout10%', **delayed_options, stage_idx: 1) - create_build('rollout100%', **delayed_options, stage_idx: 2) - create_build('cleanup', stage_idx: 3) - - allow(Ci::BuildScheduleWorker).to receive(:perform_at) - end - - context 'when builds are successful' do - it 'properly processes the pipeline' do - expect(process_pipeline).to be_truthy - expect(builds_names_and_statuses).to eq({ 'build': 'pending' }) - - succeed_pending - - expect(builds_names_and_statuses).to eq({ 'build': 'success', 'rollout10%': 'scheduled' }) - - enqueue_scheduled('rollout10%') - succeed_pending - - expect(builds_names_and_statuses).to eq({ 'build': 'success', 'rollout10%': 'success', 'rollout100%': 'scheduled' }) - - enqueue_scheduled('rollout100%') - succeed_pending - - expect(builds_names_and_statuses).to eq({ 'build': 'success', 'rollout10%': 'success', 'rollout100%': 'success', 'cleanup': 'pending' }) - - succeed_pending - - expect(builds_names_and_statuses).to eq({ 'build': 'success', 'rollout10%': 'success', 'rollout100%': 'success', 'cleanup': 'success' }) - expect(pipeline.reload.status).to eq 'success' - end - end - - context 'when build job fails' do - it 'properly processes the pipeline' do - expect(process_pipeline).to be_truthy - expect(builds_names_and_statuses).to eq({ 'build': 'pending' }) - - fail_running_or_pending - - expect(builds_names_and_statuses).to eq({ 'build': 'failed' }) - expect(pipeline.reload.status).to eq 'failed' - end - end - - context 'when rollout 10% is unscheduled' do - it 'properly processes the pipeline' do - expect(process_pipeline).to be_truthy - expect(builds_names_and_statuses).to eq({ 'build': 'pending' }) - - succeed_pending - - expect(builds_names_and_statuses).to eq({ 'build': 'success', 'rollout10%': 'scheduled' }) - - unschedule - - expect(builds_names_and_statuses).to eq({ 'build': 'success', 'rollout10%': 'manual' }) - expect(pipeline.reload.status).to eq 'manual' - end - - context 'when user plays rollout 10%' do - it 'schedules rollout100%' do - process_pipeline - succeed_pending - unschedule - play_manual_action('rollout10%') - succeed_pending - - expect(builds_names_and_statuses).to eq({ 'build': 'success', 'rollout10%': 'success', 'rollout100%': 'scheduled' }) - expect(pipeline.reload.status).to eq 'scheduled' - end - end - end - - context 'when rollout 10% fails' do - it 'properly processes the pipeline' do - expect(process_pipeline).to be_truthy - expect(builds_names_and_statuses).to eq({ 'build': 'pending' }) - - succeed_pending - - expect(builds_names_and_statuses).to eq({ 'build': 'success', 'rollout10%': 'scheduled' }) - - enqueue_scheduled('rollout10%') - fail_running_or_pending - - expect(builds_names_and_statuses).to eq({ 'build': 'success', 'rollout10%': 'failed' }) - expect(pipeline.reload.status).to eq 'failed' - end - - context 'when user retries rollout 10%' do - it 'does not schedule rollout10% again' do - process_pipeline - succeed_pending - enqueue_scheduled('rollout10%') - fail_running_or_pending - retry_build('rollout10%') - - expect(builds_names_and_statuses).to eq({ 'build': 'success', 'rollout10%': 'pending' }) - expect(pipeline.reload.status).to eq 'running' - end - end - end - - context 'when rollout 10% is played immidiately' do - it 'properly processes the pipeline' do - expect(process_pipeline).to be_truthy - expect(builds_names_and_statuses).to eq({ 'build': 'pending' }) - - succeed_pending - - expect(builds_names_and_statuses).to eq({ 'build': 'success', 'rollout10%': 'scheduled' }) - - play_manual_action('rollout10%') - - expect(builds_names_and_statuses).to eq({ 'build': 'success', 'rollout10%': 'pending' }) - expect(pipeline.reload.status).to eq 'running' - end - end - end - - context 'when only one scheduled job exists in a pipeline' do - before do - create_build('delayed', **delayed_options, stage_idx: 0) - - allow(Ci::BuildScheduleWorker).to receive(:perform_at) - end - - it 'properly processes the pipeline' do - expect(process_pipeline).to be_truthy - expect(builds_names_and_statuses).to eq({ 'delayed': 'scheduled' }) - - expect(pipeline.reload.status).to eq 'scheduled' - end - end - - context 'when there are two delayed jobs in a stage' do - before do - create_build('delayed1', **delayed_options, stage_idx: 0) - create_build('delayed2', **delayed_options, stage_idx: 0) - create_build('job', stage_idx: 1) - - allow(Ci::BuildScheduleWorker).to receive(:perform_at) - end - - it 'blocks the stage until all scheduled jobs finished' do - expect(process_pipeline).to be_truthy - expect(builds_names_and_statuses).to eq({ 'delayed1': 'scheduled', 'delayed2': 'scheduled' }) - - enqueue_scheduled('delayed1') - - expect(builds_names_and_statuses).to eq({ 'delayed1': 'pending', 'delayed2': 'scheduled' }) - expect(pipeline.reload.status).to eq 'running' - end - end - - context 'when a delayed job is allowed to fail' do - before do - create_build('delayed', **delayed_options, allow_failure: true, stage_idx: 0) - create_build('job', stage_idx: 1) - - allow(Ci::BuildScheduleWorker).to receive(:perform_at) - end - - it 'blocks the stage and continues after it failed' do - expect(process_pipeline).to be_truthy - expect(builds_names_and_statuses).to eq({ 'delayed': 'scheduled' }) - - enqueue_scheduled('delayed') - fail_running_or_pending - - expect(builds_names_and_statuses).to eq({ 'delayed': 'failed', 'job': 'pending' }) - expect(pipeline.reload.status).to eq 'pending' - end - end - end - - context 'when an exception is raised during a persistent ref creation' do - before do - successful_build('test', stage_idx: 0) - - allow_next_instance_of(Ci::PersistentRef) do |instance| - allow(instance).to receive(:delete_refs) { raise ArgumentError } - end - end - - it 'process the pipeline' do - expect { process_pipeline }.not_to raise_error - end - end - - context 'when there are manual action in earlier stages' do - context 'when first stage has only optional manual actions' do - before do - create_build('build', stage_idx: 0, when: 'manual', allow_failure: true) - create_build('check', stage_idx: 1) - create_build('test', stage_idx: 2) - - process_pipeline - end - - it 'starts from the second stage' do - expect(all_builds_statuses).to eq %w[manual pending created] - end - end - - context 'when second stage has only optional manual actions' do - before do - create_build('check', stage_idx: 0) - create_build('build', stage_idx: 1, when: 'manual', allow_failure: true) - create_build('test', stage_idx: 2) - - process_pipeline - end - - it 'skips second stage and continues on third stage', :sidekiq_might_not_need_inline do - expect(all_builds_statuses).to eq(%w[pending created created]) - - builds.first.success - - expect(all_builds_statuses).to eq(%w[success manual pending]) - end - end - end - - context 'when there are only manual actions in stages' do - before do - create_build('image', stage_idx: 0, when: 'manual', allow_failure: true) - create_build('build', stage_idx: 1, when: 'manual', allow_failure: true) - create_build('deploy', stage_idx: 2, when: 'manual') - create_build('check', stage_idx: 3) - - process_pipeline - end - - it 'processes all jobs until blocking actions encountered' do - expect(all_builds_statuses).to eq(%w[manual manual manual created]) - expect(all_builds_names).to eq(%w[image build deploy check]) - - expect(pipeline.reload).to be_blocked - end - end - - context 'when there is only one manual action' do - before do - create_build('deploy', stage_idx: 0, when: 'manual', allow_failure: true) - - process_pipeline - end - - it 'skips the pipeline' do - expect(pipeline.reload).to be_skipped - end - - context 'when the action was played' do - before do - play_manual_action('deploy') - end - - it 'queues the action and pipeline', :sidekiq_might_not_need_inline do - expect(all_builds_statuses).to eq(%w[pending]) - - expect(pipeline.reload).to be_pending - end - end - end - - context 'when blocking manual actions are defined', :sidekiq_might_not_need_inline do - before do - create_build('code:test', stage_idx: 0) - create_build('staging:deploy', stage_idx: 1, when: 'manual') - create_build('staging:test', stage_idx: 2, when: 'on_success') - create_build('production:deploy', stage_idx: 3, when: 'manual') - create_build('production:test', stage_idx: 4, when: 'always') - end - - context 'when first stage succeeds' do - it 'blocks pipeline on stage with first manual action' do - process_pipeline - - expect(builds_names).to eq %w[code:test] - expect(builds_statuses).to eq %w[pending] - expect(pipeline.reload.status).to eq 'pending' - - succeed_running_or_pending - - expect(builds_names).to eq %w[code:test staging:deploy] - expect(builds_statuses).to eq %w[success manual] - expect(pipeline.reload).to be_manual - end - end - - context 'when first stage fails' do - it 'does not take blocking action into account' do - process_pipeline - - expect(builds_names).to eq %w[code:test] - expect(builds_statuses).to eq %w[pending] - expect(pipeline.reload.status).to eq 'pending' - - fail_running_or_pending - - expect(builds_names).to eq %w[code:test production:test] - expect(builds_statuses).to eq %w[failed pending] - - succeed_running_or_pending - - expect(builds_statuses).to eq %w[failed success] - expect(pipeline.reload).to be_failed - end - end - - context 'when pipeline is promoted sequentially up to the end' do - before do - # Users need ability to merge into a branch in order to trigger - # protected manual actions. - # - create(:protected_branch, :developers_can_merge, - name: 'master', project: project) - end - - it 'properly processes entire pipeline' do - process_pipeline - - expect(builds_names).to eq %w[code:test] - expect(builds_statuses).to eq %w[pending] - - succeed_running_or_pending - - expect(builds_names).to eq %w[code:test staging:deploy] - expect(builds_statuses).to eq %w[success manual] - expect(pipeline.reload).to be_manual - - play_manual_action('staging:deploy') - - expect(builds_statuses).to eq %w[success pending] - - succeed_running_or_pending - - expect(builds_names).to eq %w[code:test staging:deploy staging:test] - expect(builds_statuses).to eq %w[success success pending] - - succeed_running_or_pending - - expect(builds_names).to eq %w[code:test staging:deploy staging:test - production:deploy] - expect(builds_statuses).to eq %w[success success success manual] - - expect(pipeline.reload).to be_manual - expect(pipeline.reload).to be_blocked - expect(pipeline.reload).not_to be_active - expect(pipeline.reload).not_to be_complete - - play_manual_action('production:deploy') - - expect(builds_statuses).to eq %w[success success success pending] - expect(pipeline.reload).to be_running - - succeed_running_or_pending - - expect(builds_names).to eq %w[code:test staging:deploy staging:test - production:deploy production:test] - expect(builds_statuses).to eq %w[success success success success pending] - expect(pipeline.reload).to be_running - - succeed_running_or_pending - - expect(builds_names).to eq %w[code:test staging:deploy staging:test - production:deploy production:test] - expect(builds_statuses).to eq %w[success success success success success] - expect(pipeline.reload).to be_success - end - end - end - - context 'when second stage has only on_failure jobs', :sidekiq_might_not_need_inline do - before do - create_build('check', stage_idx: 0) - create_build('build', stage_idx: 1, when: 'on_failure') - create_build('test', stage_idx: 2) - - process_pipeline - end - - it 'skips second stage and continues on third stage' do - expect(all_builds_statuses).to eq(%w[pending created created]) - - builds.first.success - - expect(all_builds_statuses).to eq(%w[success skipped pending]) - end - end - - context 'when failed build in the middle stage is retried', :sidekiq_might_not_need_inline do - context 'when failed build is the only unsuccessful build in the stage' do - before do - create_build('build:1', stage_idx: 0) - create_build('build:2', stage_idx: 0) - create_build('test:1', stage_idx: 1) - create_build('test:2', stage_idx: 1) - create_build('deploy:1', stage_idx: 2) - create_build('deploy:2', stage_idx: 2) - end - - it 'does trigger builds in the next stage' do - expect(process_pipeline).to be_truthy - expect(builds_names).to eq ['build:1', 'build:2'] - - succeed_running_or_pending - - expect(builds_names).to eq ['build:1', 'build:2', 'test:1', 'test:2'] - - pipeline.builds.find_by(name: 'test:1').success - pipeline.builds.find_by(name: 'test:2').drop - - expect(builds_names).to eq ['build:1', 'build:2', 'test:1', 'test:2'] - - Ci::Build.retry(pipeline.builds.find_by(name: 'test:2'), user).success - - expect(builds_names).to eq ['build:1', 'build:2', 'test:1', 'test:2', - 'test:2', 'deploy:1', 'deploy:2'] - end - end - end - context 'updates a list of retried builds' do subject { described_class.retried.order(:id) } @@ -685,251 +33,15 @@ describe Ci::ProcessPipelineService, '#execute' do end end - context 'when builds with auto-retries are configured', :sidekiq_might_not_need_inline do - before do - create_build('build:1', stage_idx: 0, user: user, options: { script: 'aa', retry: 2 }) - create_build('test:1', stage_idx: 1, user: user, when: :on_failure) - create_build('test:2', stage_idx: 1, user: user, options: { script: 'aa', retry: 1 }) - end - - it 'automatically retries builds in a valid order' do - expect(process_pipeline).to be_truthy - - fail_running_or_pending - - expect(builds_names).to eq %w[build:1 build:1] - expect(builds_statuses).to eq %w[failed pending] - - succeed_running_or_pending - - expect(builds_names).to eq %w[build:1 build:1 test:2] - expect(builds_statuses).to eq %w[failed success pending] - - succeed_running_or_pending - - expect(builds_names).to eq %w[build:1 build:1 test:2] - expect(builds_statuses).to eq %w[failed success success] - - expect(pipeline.reload).to be_success - end - end - - context 'when pipeline with needs is created', :sidekiq_might_not_need_inline do - let!(:linux_build) { create_build('linux:build', stage: 'build', stage_idx: 0) } - let!(:mac_build) { create_build('mac:build', stage: 'build', stage_idx: 0) } - let!(:linux_rspec) { create_build('linux:rspec', stage: 'test', stage_idx: 1) } - let!(:linux_rubocop) { create_build('linux:rubocop', stage: 'test', stage_idx: 1) } - let!(:mac_rspec) { create_build('mac:rspec', stage: 'test', stage_idx: 1) } - let!(:mac_rubocop) { create_build('mac:rubocop', stage: 'test', stage_idx: 1) } - let!(:deploy) { create_build('deploy', stage: 'deploy', stage_idx: 2) } - - let!(:linux_rspec_on_build) { create(:ci_build_need, build: linux_rspec, name: 'linux:build') } - let!(:linux_rubocop_on_build) { create(:ci_build_need, build: linux_rubocop, name: 'linux:build') } - - let!(:mac_rspec_on_build) { create(:ci_build_need, build: mac_rspec, name: 'mac:build') } - let!(:mac_rubocop_on_build) { create(:ci_build_need, build: mac_rubocop, name: 'mac:build') } - - it 'when linux:* finishes first it runs it out of order' do - expect(process_pipeline).to be_truthy - - expect(stages).to eq(%w(pending created created)) - expect(builds.pending).to contain_exactly(linux_build, mac_build) - - # we follow the single path of linux - linux_build.reset.success! - - expect(stages).to eq(%w(running pending created)) - expect(builds.success).to contain_exactly(linux_build) - expect(builds.pending).to contain_exactly(mac_build, linux_rspec, linux_rubocop) - - linux_rspec.reset.success! - - expect(stages).to eq(%w(running running created)) - expect(builds.success).to contain_exactly(linux_build, linux_rspec) - expect(builds.pending).to contain_exactly(mac_build, linux_rubocop) - - linux_rubocop.reset.success! - - expect(stages).to eq(%w(running running created)) - expect(builds.success).to contain_exactly(linux_build, linux_rspec, linux_rubocop) - expect(builds.pending).to contain_exactly(mac_build) - - mac_build.reset.success! - mac_rspec.reset.success! - mac_rubocop.reset.success! - - expect(stages).to eq(%w(success success pending)) - expect(builds.success).to contain_exactly( - linux_build, linux_rspec, linux_rubocop, mac_build, mac_rspec, mac_rubocop) - expect(builds.pending).to contain_exactly(deploy) - end - - context 'when feature ci_dag_support is disabled' do - before do - stub_feature_flags(ci_dag_support: false) - end - - it 'when linux:build finishes first it follows stages' do - expect(process_pipeline).to be_truthy - - expect(stages).to eq(%w(pending created created)) - expect(builds.pending).to contain_exactly(linux_build, mac_build) - - # we follow the single path of linux - linux_build.reset.success! - - expect(stages).to eq(%w(running created created)) - expect(builds.success).to contain_exactly(linux_build) - expect(builds.pending).to contain_exactly(mac_build) - - mac_build.reset.success! - - expect(stages).to eq(%w(success pending created)) - expect(builds.success).to contain_exactly(linux_build, mac_build) - expect(builds.pending).to contain_exactly( - linux_rspec, linux_rubocop, mac_rspec, mac_rubocop) - - linux_rspec.reset.success! - linux_rubocop.reset.success! - mac_rspec.reset.success! - mac_rubocop.reset.success! - - expect(stages).to eq(%w(success success pending)) - expect(builds.success).to contain_exactly( - linux_build, linux_rspec, linux_rubocop, mac_build, mac_rspec, mac_rubocop) - expect(builds.pending).to contain_exactly(deploy) - end - end - - context 'when one of the jobs is run on a failure' do - let!(:linux_notify) { create_build('linux:notify', stage: 'deploy', stage_idx: 2, when: 'on_failure') } - - let!(:linux_notify_on_build) { create(:ci_build_need, build: linux_notify, name: 'linux:build') } - - context 'when another job in build phase fails first' do - context 'when ci_dag_support is enabled' do - it 'does skip linux:notify' do - expect(process_pipeline).to be_truthy - - mac_build.reset.drop! - linux_build.reset.success! - - expect(linux_notify.reset).to be_skipped - end - end - - context 'when ci_dag_support is disabled' do - before do - stub_feature_flags(ci_dag_support: false) - end - - it 'does run linux:notify' do - expect(process_pipeline).to be_truthy - - mac_build.reset.drop! - linux_build.reset.success! - - expect(linux_notify.reset).to be_pending - end - end - end - - context 'when linux:build job fails first' do - it 'does run linux:notify' do - expect(process_pipeline).to be_truthy - - linux_build.reset.drop! - - expect(linux_notify.reset).to be_pending - end - end - end - end - def process_pipeline described_class.new(pipeline).execute end - def all_builds - pipeline.builds.order(:stage_idx, :id) - end - - def builds - all_builds.where.not(status: [:created, :skipped]) - end - - def stages - pipeline.reset.stages.map(&:status) - end - - def builds_names - builds.pluck(:name) - end - - def builds_names_and_statuses - builds.each_with_object({}) do |b, h| - h[b.name.to_sym] = b.status - h - end - end - - def all_builds_names - all_builds.pluck(:name) - end - - def builds_statuses - builds.pluck(:status) - end - - def all_builds_statuses - all_builds.pluck(:status) - end - - def succeed_pending - builds.pending.map(&:success) - end - - def succeed_running_or_pending - pipeline.builds.running_or_pending.each(&:success) - end - - def fail_running_or_pending - pipeline.builds.running_or_pending.each(&:drop) - end - - def cancel_running_or_pending - pipeline.builds.running_or_pending.each(&:cancel) - end - - def play_manual_action(name) - builds.find_by(name: name).play(user) - end - - def enqueue_scheduled(name) - builds.scheduled.find_by(name: name).enqueue - end - - def retry_build(name) - Ci::Build.retry(builds.find_by(name: name), user) - end - - def manual_actions - pipeline.manual_actions.reload - end - def create_build(name, **opts) create(:ci_build, :created, pipeline: pipeline, name: name, **opts) end - def successful_build(name, **opts) - create(:ci_build, :success, pipeline: pipeline, name: name, **opts) - end - - def delayed_options - { when: 'delayed', options: { script: %w(echo), start_in: '1 minute' } } - end - - def unschedule - pipeline.builds.scheduled.map(&:unschedule) + def all_builds + pipeline.builds.order(:stage_idx, :id) end end diff --git a/spec/services/ci/register_job_service_spec.rb b/spec/services/ci/register_job_service_spec.rb index 0339c6cc2d6567eb24a1215b072e6965714014ba..0f2d994efd4b0cdc265ae4ed1cc9525be220a0fa 100644 --- a/spec/services/ci/register_job_service_spec.rb +++ b/spec/services/ci/register_job_service_spec.rb @@ -4,9 +4,9 @@ require 'spec_helper' module Ci describe RegisterJobService do - set(:group) { create(:group) } - set(:project) { create(:project, group: group, shared_runners_enabled: false, group_runners_enabled: false) } - set(:pipeline) { create(:ci_pipeline, project: project) } + let_it_be(:group) { create(:group) } + let_it_be(:project, reload: true) { create(:project, group: group, shared_runners_enabled: false, group_runners_enabled: false) } + let_it_be(:pipeline) { create(:ci_pipeline, project: project) } let!(:shared_runner) { create(:ci_runner, :instance) } let!(:specific_runner) { create(:ci_runner, :project, projects: [project]) } let!(:group_runner) { create(:ci_runner, :group, groups: [group]) } diff --git a/spec/services/ci/resource_groups/assign_resource_from_resource_group_service_spec.rb b/spec/services/ci/resource_groups/assign_resource_from_resource_group_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..50d312647ae6ab1fd268712518b70a0bc9ee746c --- /dev/null +++ b/spec/services/ci/resource_groups/assign_resource_from_resource_group_service_spec.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Ci::ResourceGroups::AssignResourceFromResourceGroupService do + let_it_be(:project) { create(:project) } + let_it_be(:user) { create(:user) } + let(:service) { described_class.new(project, user) } + + describe '#execute' do + subject { service.execute(resource_group) } + + let(:resource_group) { create(:ci_resource_group, project: project) } + let!(:build) { create(:ci_build, :waiting_for_resource, project: project, user: user, resource_group: resource_group) } + + context 'when there is an available resource' do + it 'requests resource' do + subject + + expect(build.reload).to be_pending + expect(build.resource).to be_present + end + + context 'when failed to request resource' do + before do + allow_next_instance_of(Ci::Build) do |build| + allow(build).to receive(:enqueue_waiting_for_resource) { false } + end + end + + it 'has a build waiting for resource' do + subject + + expect(build).to be_waiting_for_resource + end + end + + context 'when the build has already retained a resource' do + before do + resource_group.assign_resource_to(build) + build.update_column(:status, :pending) + end + + it 'has a pending build' do + subject + + expect(build).to be_pending + end + end + end + + context 'when there are no available resources' do + before do + resource_group.assign_resource_to(create(:ci_build)) + end + + it 'does not request resource' do + expect_any_instance_of(Ci::Build).not_to receive(:enqueue_waiting_for_resource) + + subject + 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 b1368f7776ba968dd1384017591c42f9ffec5960..b3189974440cbc1bf5afe24f8d74b984450c0eb2 100644 --- a/spec/services/ci/retry_build_service_spec.rb +++ b/spec/services/ci/retry_build_service_spec.rb @@ -3,9 +3,12 @@ require 'spec_helper' describe Ci::RetryBuildService do - set(:user) { create(:user) } - set(:project) { create(:project) } - set(:pipeline) { create(:ci_pipeline, project: project) } + let_it_be(:user) { create(:user) } + let_it_be(:project) { create(:project, :repository) } + let_it_be(:pipeline) do + create(:ci_pipeline, project: project, + sha: 'b83d6e391c22777fca1ed3012fce84f633d7fed0') + end let(:stage) do create(:ci_stage_entity, project: project, @@ -29,9 +32,11 @@ describe Ci::RetryBuildService do job_artifacts_metadata job_artifacts_trace job_artifacts_junit job_artifacts_sast job_artifacts_dependency_scanning job_artifacts_container_scanning job_artifacts_dast - job_artifacts_license_management job_artifacts_performance + job_artifacts_license_management job_artifacts_license_scanning + job_artifacts_performance job_artifacts_codequality job_artifacts_metrics scheduled_at - job_variables].freeze + job_variables waiting_for_resource_at job_artifacts_metrics_referee + job_artifacts_network_referee].freeze IGNORE_ACCESSORS = %i[type lock_version target_url base_tags trace_sections @@ -40,14 +45,15 @@ describe Ci::RetryBuildService do user_id auto_canceled_by_id retried failure_reason sourced_pipelines artifacts_file_store artifacts_metadata_store metadata runner_session trace_chunks upstream_pipeline_id - artifacts_file artifacts_metadata artifacts_size commands].freeze + artifacts_file artifacts_metadata artifacts_size commands + resource resource_group_id processed].freeze shared_examples 'build duplication' do let(:another_pipeline) { create(:ci_empty_pipeline, project: project) } let(:build) do create(:ci_build, :failed, :expired, :erased, :queued, :coverage, :tags, - :allowed_to_fail, :on_tag, :triggered, :teardown_environment, + :allowed_to_fail, :on_tag, :triggered, :teardown_environment, :resource_group, description: 'my-job', stage: 'test', stage_id: stage.id, pipeline: pipeline, auto_canceled_by: another_pipeline, scheduled_at: 10.seconds.since) @@ -197,17 +203,19 @@ describe Ci::RetryBuildService do it 'does not enqueue the new build' do expect(new_build).to be_created + expect(new_build).not_to be_processed end - it 'does mark old build as retried in the database and on the instance' do + it 'does mark old build as retried' do expect(new_build).to be_latest expect(build).to be_retried - expect(build.reload).to be_retried + expect(build).to be_processed end context 'when build with deployment is retried' do let!(:build) do - create(:ci_build, :with_deployment, :deploy_to_production, pipeline: pipeline, stage_id: stage.id) + create(:ci_build, :with_deployment, :deploy_to_production, + pipeline: pipeline, stage_id: stage.id, project: project) end it 'creates a new deployment' do diff --git a/spec/services/ci/retry_pipeline_service_spec.rb b/spec/services/ci/retry_pipeline_service_spec.rb index 4b949761b8fd2d87241fd3cb4bc6318b72c0e9a5..e7a241ed33588f32f7e6e115bcae26267996b492 100644 --- a/spec/services/ci/retry_pipeline_service_spec.rb +++ b/spec/services/ci/retry_pipeline_service_spec.rb @@ -330,7 +330,7 @@ describe Ci::RetryPipelineService, '#execute' do stage: "stage_#{stage_num}", stage_idx: stage_num, pipeline: pipeline, **opts) do |build| - pipeline.update_status + pipeline.update_legacy_status end end end diff --git a/spec/services/ci/run_scheduled_build_service_spec.rb b/spec/services/ci/run_scheduled_build_service_spec.rb index ab8d9f4ba2eb0403e44bd5319b01199549696dcb..43d110cbc8fce42c7b40bede5024446191254d99 100644 --- a/spec/services/ci/run_scheduled_build_service_spec.rb +++ b/spec/services/ci/run_scheduled_build_service_spec.rb @@ -26,6 +26,18 @@ describe Ci::RunScheduledBuildService do expect(build).to be_pending end + + context 'when build requires resource' do + let(:resource_group) { create(:ci_resource_group, project: project) } + + before do + build.update!(resource_group: resource_group) + end + + it 'transits to waiting for resource status' do + expect { subject }.to change { build.status }.from('scheduled').to('waiting_for_resource') + end + end end context 'when scheduled_at is not expired' do diff --git a/spec/services/clusters/applications/check_installation_progress_service_spec.rb b/spec/services/clusters/applications/check_installation_progress_service_spec.rb index 7b37eb978001c47cdd5d19d541fb7aad9c61f1e6..2f224d40920d247a61f4ee4b258df59fee4e2198 100644 --- a/spec/services/clusters/applications/check_installation_progress_service_spec.rb +++ b/spec/services/clusters/applications/check_installation_progress_service_spec.rb @@ -160,6 +160,12 @@ describe Clusters::Applications::CheckInstallationProgressService, '#execute' do expect(application).to be_installed expect(application.status_reason).to be_nil end + + it 'tracks application install' do + expect(Gitlab::Tracking).to receive(:event).with('cluster:applications', "cluster_application_helm_installed") + + service.execute + end end context 'when installation POD failed' do diff --git a/spec/services/clusters/applications/create_service_spec.rb b/spec/services/clusters/applications/create_service_spec.rb index bdacb9ce071ec277579dbf46d5b941b4b9c75a3b..f62af86f1bf179c868f4fe7ace5b73ad4750cdde 100644 --- a/spec/services/clusters/applications/create_service_spec.rb +++ b/spec/services/clusters/applications/create_service_spec.rb @@ -47,6 +47,33 @@ describe Clusters::Applications::CreateService do create(:clusters_applications_helm, :installed, cluster: cluster) end + context 'ingress application' do + let(:params) do + { + application: 'ingress', + modsecurity_enabled: true + } + end + + before do + expect_any_instance_of(Clusters::Applications::Ingress) + .to receive(:make_scheduled!) + .and_call_original + end + + it 'creates the application' do + expect do + subject + + cluster.reload + end.to change(cluster, :application_ingress) + end + + it 'sets modsecurity_enabled' do + expect(subject.modsecurity_enabled).to eq(true) + end + end + context 'cert manager application' do let(:params) do { @@ -136,8 +163,7 @@ describe Clusters::Applications::CreateService do context 'elastic stack application' do let(:params) do { - application: 'elastic_stack', - kibana_hostname: 'example.com' + application: 'elastic_stack' } end @@ -155,10 +181,6 @@ describe Clusters::Applications::CreateService do cluster.reload end.to change(cluster, :application_elastic_stack) end - - it 'sets the kibana_hostname' do - expect(subject.kibana_hostname).to eq('example.com') - end end end diff --git a/spec/services/clusters/kubernetes/create_or_update_namespace_service_spec.rb b/spec/services/clusters/kubernetes/create_or_update_namespace_service_spec.rb index bd1a90996a83b313f88ab8f6d445aa30a7f1bc12..3982d2310d81fe4ee3201a1c3e4c4a741e725eb8 100644 --- a/spec/services/clusters/kubernetes/create_or_update_namespace_service_spec.rb +++ b/spec/services/clusters/kubernetes/create_or_update_namespace_service_spec.rb @@ -57,11 +57,21 @@ describe Clusters::Kubernetes::CreateOrUpdateNamespaceService, '#execute' do end.to change(Clusters::KubernetesNamespace, :count).by(1) end - it 'creates project service account' do - expect_next_instance_of(Clusters::Kubernetes::CreateOrUpdateServiceAccountService) do |instance| - expect(instance).to receive(:execute).once - end - + it 'creates project service account and namespace' do + account_service = double(Clusters::Kubernetes::CreateOrUpdateServiceAccountService) + expect(Clusters::Kubernetes::CreateOrUpdateServiceAccountService).to( + receive(:namespace_creator).with( + cluster.platform.kubeclient, + service_account_name: "#{namespace}-service-account", + service_account_namespace: namespace, + service_account_namespace_labels: { + 'app.gitlab.com/app' => project.full_path_slug, + 'app.gitlab.com/env' => environment.slug + }, + rbac: true + ).and_return(account_service) + ) + expect(account_service).to receive(:execute).once subject end @@ -73,6 +83,29 @@ describe Clusters::Kubernetes::CreateOrUpdateNamespaceService, '#execute' do expect(kubernetes_namespace.service_account_name).to eq("#{namespace}-service-account") expect(kubernetes_namespace.encrypted_service_account_token).to be_present end + + context 'without environment' do + before do + kubernetes_namespace.environment = nil + end + + it 'creates project service account and namespace' do + account_service = double(Clusters::Kubernetes::CreateOrUpdateServiceAccountService) + expect(Clusters::Kubernetes::CreateOrUpdateServiceAccountService).to( + receive(:namespace_creator).with( + cluster.platform.kubeclient, + service_account_name: "#{namespace}-service-account", + service_account_namespace: namespace, + service_account_namespace_labels: { + 'app.gitlab.com/app' => project.full_path_slug + }, + rbac: true + ).and_return(account_service) + ) + expect(account_service).to receive(:execute).once + subject + end + end end context 'group clusters' do diff --git a/spec/services/clusters/kubernetes/create_or_update_service_account_service_spec.rb b/spec/services/clusters/kubernetes/create_or_update_service_account_service_spec.rb index 4df73fcc2aec8262e5bb938ee3feb9ca06f36e18..8fa22422074cb85b7b9fc61682635b26a65ab340 100644 --- a/spec/services/clusters/kubernetes/create_or_update_service_account_service_spec.rb +++ b/spec/services/clusters/kubernetes/create_or_update_service_account_service_spec.rb @@ -116,6 +116,7 @@ describe Clusters::Kubernetes::CreateOrUpdateServiceAccountService do describe '.namespace_creator' do let(:namespace) { "#{project.path}-#{project.id}" } + let(:namespace_labels) { { app: project.full_path_slug, env: "staging" } } let(:service_account_name) { "#{namespace}-service-account" } let(:token_name) { "#{namespace}-token" } @@ -124,6 +125,7 @@ describe Clusters::Kubernetes::CreateOrUpdateServiceAccountService do kubeclient, service_account_name: service_account_name, service_account_namespace: namespace, + service_account_namespace_labels: namespace_labels, rbac: rbac ).execute end @@ -149,6 +151,16 @@ describe Clusters::Kubernetes::CreateOrUpdateServiceAccountService do stub_kubeclient_put_role_binding(api_url, Clusters::Kubernetes::GITLAB_CROSSPLANE_DATABASE_ROLE_BINDING_NAME, namespace: namespace) end + it 'creates a namespace object' do + kubernetes_namespace = double(Gitlab::Kubernetes::Namespace) + expect(Gitlab::Kubernetes::Namespace).to( + receive(:new).with(namespace, kubeclient, labels: namespace_labels).and_return(kubernetes_namespace) + ) + expect(kubernetes_namespace).to receive(:ensure_exists!) + + subject + end + it_behaves_like 'creates service account and token' it 'creates a namespaced role binding with edit access' do diff --git a/spec/services/container_expiration_policy_service_spec.rb b/spec/services/container_expiration_policy_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..1e4899c627fe11b248ff8cb4e4a84d074848ca82 --- /dev/null +++ b/spec/services/container_expiration_policy_service_spec.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe ContainerExpirationPolicyService do + let_it_be(:user) { create(:user) } + let_it_be(:container_expiration_policy) { create(:container_expiration_policy, :runnable) } + let(:project) { container_expiration_policy.project } + let(:container_repository) { create(:container_repository, project: project) } + + before do + project.add_maintainer(user) + end + + describe '#execute' do + subject { described_class.new(project, user).execute(container_expiration_policy) } + + it 'kicks off a cleanup worker for the container repository' do + expect(CleanupContainerRepositoryWorker).to receive(:perform_async) + .with(user.id, container_repository.id, anything) + + subject + end + + it 'sets next_run_at on the container_expiration_policy' do + subject + + expect(container_expiration_policy.next_run_at).to be > Time.zone.now + end + end +end diff --git a/spec/services/create_snippet_service_spec.rb b/spec/services/create_snippet_service_spec.rb deleted file mode 100644 index 1751029a78c5d3695aa77c214c8b88352e468034..0000000000000000000000000000000000000000 --- a/spec/services/create_snippet_service_spec.rb +++ /dev/null @@ -1,117 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -describe CreateSnippetService do - let(:user) { create(:user) } - let(:admin) { create(:user, :admin) } - let(:opts) { base_opts.merge(extra_opts) } - let(:base_opts) do - { - title: 'Test snippet', - file_name: 'snippet.rb', - content: 'puts "hello world"', - visibility_level: Gitlab::VisibilityLevel::PRIVATE - } - end - let(:extra_opts) { {} } - - context 'When public visibility is restricted' do - let(:extra_opts) { { visibility_level: Gitlab::VisibilityLevel::PUBLIC } } - - before do - stub_application_setting(restricted_visibility_levels: [Gitlab::VisibilityLevel::PUBLIC]) - end - - it 'non-admins are not able to create a public snippet' do - snippet = create_snippet(nil, user, opts) - expect(snippet.errors.messages).to have_key(:visibility_level) - expect(snippet.errors.messages[:visibility_level].first).to( - match('has been restricted') - ) - end - - it 'admins are able to create a public snippet' do - snippet = create_snippet(nil, admin, opts) - expect(snippet.errors.any?).to be_falsey - expect(snippet.visibility_level).to eq(Gitlab::VisibilityLevel::PUBLIC) - end - - describe "when visibility level is passed as a string" do - let(:extra_opts) { { visibility: 'internal' } } - - before do - base_opts.delete(:visibility_level) - end - - it "assigns the correct visibility level" do - snippet = create_snippet(nil, user, opts) - expect(snippet.errors.any?).to be_falsey - expect(snippet.visibility_level).to eq(Gitlab::VisibilityLevel::INTERNAL) - end - end - end - - context 'checking spam' do - shared_examples 'marked as spam' do - let(:snippet) { create_snippet(nil, admin, opts) } - - it 'marks a snippet as a spam ' do - expect(snippet).to be_spam - end - - it 'invalidates the snippet' do - expect(snippet).to be_invalid - end - - it 'creates a new spam_log' do - expect { snippet } - .to log_spam(title: snippet.title, noteable_type: 'PersonalSnippet') - end - - it 'assigns a spam_log to an issue' do - expect(snippet.spam_log).to eq(SpamLog.last) - end - end - - let(:extra_opts) do - { visibility_level: Gitlab::VisibilityLevel::PUBLIC, request: double(:request, env: {}) } - end - - before do - expect_next_instance_of(AkismetService) do |akismet_service| - expect(akismet_service).to receive_messages(spam?: true) - end - end - - [true, false, nil].each do |allow_possible_spam| - context "when recaptcha_disabled flag is #{allow_possible_spam.inspect}" do - before do - stub_feature_flags(allow_possible_spam: allow_possible_spam) unless allow_possible_spam.nil? - end - - it_behaves_like 'marked as spam' - end - end - end - - describe 'usage counter' do - let(:counter) { Gitlab::UsageDataCounters::SnippetCounter } - - it 'increments count' do - expect do - create_snippet(nil, admin, opts) - end.to change { counter.read(:create) }.by 1 - end - - it 'does not increment count if create fails' do - expect do - create_snippet(nil, admin, {}) - end.not_to change { counter.read(:create) } - end - end - - def create_snippet(project, user, opts) - CreateSnippetService.new(project, user, opts).execute - end -end diff --git a/spec/services/deployments/after_create_service_spec.rb b/spec/services/deployments/after_create_service_spec.rb index 4ca96658db093f72cd2bfb6787e9baf91882c194..51c6de2c0b951e15a2941521108c80ceff604296 100644 --- a/spec/services/deployments/after_create_service_spec.rb +++ b/spec/services/deployments/after_create_service_spec.rb @@ -6,10 +6,18 @@ describe Deployments::AfterCreateService do let(:user) { create(:user) } let(:project) { create(:project, :repository) } let(:options) { { name: 'production' } } + let(:pipeline) do + create( + :ci_pipeline, + sha: 'b83d6e391c22777fca1ed3012fce84f633d7fed0', + project: project + ) + end let(:job) do create(:ci_build, :with_deployment, + pipeline: pipeline, ref: 'master', tag: false, environment: 'production', @@ -53,14 +61,6 @@ describe Deployments::AfterCreateService do service.execute end - it 'links merge requests to deployment' do - expect_next_instance_of(Deployments::LinkMergeRequestsService, deployment) do |link_mr_service| - expect(link_mr_service).to receive(:execute) - end - - service.execute - end - it 'returns the deployment' do expect(subject.execute).to eq(deployment) end @@ -139,6 +139,7 @@ describe Deployments::AfterCreateService do let(:job) do create(:ci_build, :with_deployment, + pipeline: pipeline, ref: 'master', environment: 'production', project: project, @@ -152,6 +153,7 @@ describe Deployments::AfterCreateService do let(:job) do create(:ci_build, :with_deployment, + pipeline: pipeline, ref: 'master', environment: 'prod-slug', project: project, @@ -165,6 +167,7 @@ describe Deployments::AfterCreateService do let(:job) do create(:ci_build, :with_deployment, + pipeline: pipeline, yaml_variables: [{ key: :APP_HOST, value: 'host' }], environment: 'production', project: project, @@ -175,7 +178,7 @@ describe Deployments::AfterCreateService do end context 'when yaml environment does not have url' do - let(:job) { create(:ci_build, :with_deployment, environment: 'staging', project: project) } + let(:job) { create(:ci_build, :with_deployment, pipeline: pipeline, environment: 'staging', project: project) } it 'returns the external_url from persisted environment' do is_expected.to be_nil @@ -202,6 +205,7 @@ describe Deployments::AfterCreateService do let(:job) do create(:ci_build, :with_deployment, + pipeline: pipeline, ref: 'master', tag: false, environment: 'staging', @@ -260,30 +264,4 @@ describe Deployments::AfterCreateService do end end end - - describe '#update_environment' do - it 'links the merge requests' do - double = instance_double(Deployments::LinkMergeRequestsService) - - allow(Deployments::LinkMergeRequestsService) - .to receive(:new) - .with(deployment) - .and_return(double) - - expect(double).to receive(:execute) - - service.update_environment(deployment) - end - - context 'when the tracking of merge requests is disabled' do - it 'does nothing' do - stub_feature_flags(deployment_merge_requests: false) - - expect(Deployments::LinkMergeRequestsService) - .not_to receive(:new) - - service.update_environment(deployment) - end - end - end end diff --git a/spec/services/deployments/link_merge_requests_service_spec.rb b/spec/services/deployments/link_merge_requests_service_spec.rb index ba069658dfd6f14ad42dd01cd42b64c97e341e49..307fe22a192c2515a1e15fdc8b868abcd1b830b5 100644 --- a/spec/services/deployments/link_merge_requests_service_spec.rb +++ b/spec/services/deployments/link_merge_requests_service_spec.rb @@ -3,10 +3,15 @@ require 'spec_helper' describe Deployments::LinkMergeRequestsService do + let(:project) { create(:project, :repository) } + describe '#execute' do - context 'when the deployment did not succeed' do + context 'when the deployment is for a review environment' do it 'does nothing' do - deploy = create(:deployment, :failed) + environment = + create(:environment, environment_type: 'review', name: 'review/foo') + + deploy = create(:deployment, :success, environment: environment) expect(deploy).not_to receive(:link_merge_requests) @@ -16,20 +21,29 @@ describe Deployments::LinkMergeRequestsService do context 'when there is a previous deployment' do it 'links all merge requests merged since the previous deployment' do - deploy1 = create(:deployment, :success, sha: 'foo') + deploy1 = create( + :deployment, + :success, + project: project, + sha: '7975be0116940bf2ad4321f79d02a55c5f7779aa' + ) + deploy2 = create( :deployment, :success, - sha: 'bar', project: deploy1.project, - environment: deploy1.environment + environment: deploy1.environment, + sha: 'ddd0f15ae83993f5cb66a927a28673882e99100b' ) service = described_class.new(deploy2) expect(service) .to receive(:link_merge_requests_for_range) - .with('foo', 'bar') + .with( + '7975be0116940bf2ad4321f79d02a55c5f7779aa', + 'ddd0f15ae83993f5cb66a927a28673882e99100b' + ) service.execute end @@ -37,7 +51,7 @@ describe Deployments::LinkMergeRequestsService do context 'when there are no previous deployments' do it 'links all merged merge requests' do - deploy = create(:deployment, :success) + deploy = create(:deployment, :success, project: project) service = described_class.new(deploy) expect(service).to receive(:link_all_merged_merge_requests) @@ -49,7 +63,6 @@ describe Deployments::LinkMergeRequestsService do describe '#link_merge_requests_for_range' do it 'links merge requests' do - project = create(:project, :repository) environment = create(:environment, project: project) deploy = create(:deployment, :success, project: project, environment: environment) @@ -81,7 +94,6 @@ describe Deployments::LinkMergeRequestsService do describe '#link_all_merged_merge_requests' do it 'links all merged merge requests targeting the deployed branch' do - project = create(:project, :repository) environment = create(:environment, project: project) deploy = create(:deployment, :success, project: project, environment: environment) diff --git a/spec/services/error_tracking/list_issues_service_spec.rb b/spec/services/error_tracking/list_issues_service_spec.rb index e0e280591cdd0a844764820d67dd04af9ff1412f..ecb6bcc541bb931e3679d3a8717173618499149d 100644 --- a/spec/services/error_tracking/list_issues_service_spec.rb +++ b/spec/services/error_tracking/list_issues_service_spec.rb @@ -3,8 +3,8 @@ require 'spec_helper' describe ErrorTracking::ListIssuesService do - set(:user) { create(:user) } - set(:project) { create(:project) } + let_it_be(:user) { create(:user) } + let_it_be(:project) { create(:project) } let(:params) { { search_term: 'something', sort: 'last_seen', cursor: 'some-cursor' } } let(:list_sentry_issues_args) do { diff --git a/spec/services/error_tracking/list_projects_service_spec.rb b/spec/services/error_tracking/list_projects_service_spec.rb index cd4b835e0974aa56a8fc277f01e499d82e3d381d..ddd369d45f21515eebc1d37a8e32f68860ed4d43 100644 --- a/spec/services/error_tracking/list_projects_service_spec.rb +++ b/spec/services/error_tracking/list_projects_service_spec.rb @@ -3,8 +3,8 @@ require 'spec_helper' describe ErrorTracking::ListProjectsService do - set(:user) { create(:user) } - set(:project) { create(:project) } + let_it_be(:user) { create(:user) } + let_it_be(:project, reload: true) { create(:project) } let(:sentry_url) { 'https://sentrytest.gitlab.com/api/0/projects/sentry-org/sentry-project' } let(:token) { 'test-token' } diff --git a/spec/services/external_pull_requests/create_pipeline_service_spec.rb b/spec/services/external_pull_requests/create_pipeline_service_spec.rb index a4da5b38b97aa438661faaef29bfde78249c9e9b..d18939609607560a75f1eeff863eb968ddc3cda7 100644 --- a/spec/services/external_pull_requests/create_pipeline_service_spec.rb +++ b/spec/services/external_pull_requests/create_pipeline_service_spec.rb @@ -4,8 +4,8 @@ require 'spec_helper' describe ExternalPullRequests::CreatePipelineService do describe '#execute' do - set(:project) { create(:project, :repository) } - set(:user) { create(:user) } + let_it_be(:project) { create(:project, :auto_devops, :repository) } + let_it_be(:user) { create(:user) } let(:pull_request) { create(:external_pull_request, project: project) } before do diff --git a/spec/services/git/branch_push_service_spec.rb b/spec/services/git/branch_push_service_spec.rb index 19d7b84a3ce686442488b14403433c1e9b405cd4..4d7ec7ac1d837e87b4bd4d414d24edec226e552d 100644 --- a/spec/services/git/branch_push_service_spec.rb +++ b/spec/services/git/branch_push_service_spec.rb @@ -5,8 +5,8 @@ require 'spec_helper' describe Git::BranchPushService, services: true do include RepoHelpers - set(:user) { create(:user) } - set(:project) { create(:project, :repository) } + let_it_be(:user) { create(:user) } + let_it_be(:project, reload: true) { create(:project, :repository) } let(:blankrev) { Gitlab::Git::BLANK_SHA } let(:oldrev) { sample_commit.parent_id } let(:newrev) { sample_commit.id } @@ -108,7 +108,7 @@ describe Git::BranchPushService, services: true do end it 'reports an error' do - allow(Sidekiq).to receive(:server?).and_return(true) + allow(Gitlab::Runtime).to receive(:sidekiq?).and_return(true) expect(Sidekiq.logger).to receive(:warn) expect { subject }.not_to change { Ci::Pipeline.count } diff --git a/spec/services/groups/auto_devops_service_spec.rb b/spec/services/groups/auto_devops_service_spec.rb index 7591b2f6f12da52527d365beadd0a2a5fd8a98a2..63fbdc70c1b4b6cc9c8bae81efdf3e5c9ae61d6a 100644 --- a/spec/services/groups/auto_devops_service_spec.rb +++ b/spec/services/groups/auto_devops_service_spec.rb @@ -2,8 +2,8 @@ require 'spec_helper' describe Groups::AutoDevopsService, '#execute' do - set(:group) { create(:group) } - set(:user) { create(:user) } + let_it_be(:group) { create(:group) } + let_it_be(:user) { create(:user) } let(:group_params) { { auto_devops_enabled: '0' } } let(:service) { described_class.new(group, user, group_params) } diff --git a/spec/services/issues/referenced_merge_requests_service_spec.rb b/spec/services/issues/referenced_merge_requests_service_spec.rb index 61d1612829f8f0d052482a603378e84a0de10ec9..2c5af11d2e614b918133c43c2dc79fa197e9f186 100644 --- a/spec/services/issues/referenced_merge_requests_service_spec.rb +++ b/spec/services/issues/referenced_merge_requests_service_spec.rb @@ -15,16 +15,16 @@ describe Issues::ReferencedMergeRequestsService do end end - set(:user) { create(:user) } - set(:project) { create(:project, :public, :repository) } - set(:other_project) { create(:project, :public, :repository) } - set(:issue) { create(:issue, author: user, project: project) } + let_it_be(:user) { create(:user) } + let_it_be(:project) { create(:project, :public, :repository) } + let_it_be(:other_project) { create(:project, :public, :repository) } + let_it_be(:issue) { create(:issue, author: user, project: project) } - set(:closing_mr) { create_closing_mr(source_project: project) } - set(:closing_mr_other_project) { create_closing_mr(source_project: other_project) } + let_it_be(:closing_mr) { create_closing_mr(source_project: project) } + let_it_be(:closing_mr_other_project) { create_closing_mr(source_project: other_project) } - set(:referencing_mr) { create_referencing_mr(source_project: project, source_branch: 'csv') } - set(:referencing_mr_other_project) { create_referencing_mr(source_project: other_project, source_branch: 'csv') } + let_it_be(:referencing_mr) { create_referencing_mr(source_project: project, source_branch: 'csv') } + let_it_be(:referencing_mr_other_project) { create_referencing_mr(source_project: other_project, source_branch: 'csv') } let(:service) { described_class.new(project, user) } diff --git a/spec/services/issues/reorder_service_spec.rb b/spec/services/issues/reorder_service_spec.rb index b147cdf4e64767bce53620df8811ad3487ba3e10..6d72d698b1dd5a680d95558d1d899a6b0df4e068 100644 --- a/spec/services/issues/reorder_service_spec.rb +++ b/spec/services/issues/reorder_service_spec.rb @@ -3,9 +3,9 @@ require 'spec_helper' describe Issues::ReorderService do - set(:user) { create(:user) } - set(:project) { create(:project) } - set(:group) { create(:group) } + let_it_be(:user) { create(:user) } + let_it_be(:project) { create(:project) } + let_it_be(:group) { create(:group) } shared_examples 'issues reorder service' do context 'when reordering issues' do diff --git a/spec/services/issues/zoom_link_service_spec.rb b/spec/services/issues/zoom_link_service_spec.rb index f34d2a1855283163425ab4a3116d4ac462941639..3fb1eae361aac83cfd78fd0723b0ee98c3c744b6 100644 --- a/spec/services/issues/zoom_link_service_spec.rb +++ b/spec/services/issues/zoom_link_service_spec.rb @@ -3,8 +3,8 @@ require 'spec_helper' describe Issues::ZoomLinkService do - set(:user) { create(:user) } - set(:issue) { create(:issue) } + let_it_be(:user) { create(:user) } + let_it_be(:issue) { create(:issue) } let(:project) { issue.project } let(:service) { described_class.new(issue, user) } diff --git a/spec/services/merge_requests/conflicts/list_service_spec.rb b/spec/services/merge_requests/conflicts/list_service_spec.rb index 68a9c0a8b86fec23458991295a724d26b56eec29..13d693070845c69b9d783f94d067c3bef2494152 100644 --- a/spec/services/merge_requests/conflicts/list_service_spec.rb +++ b/spec/services/merge_requests/conflicts/list_service_spec.rb @@ -74,7 +74,9 @@ describe MergeRequests::Conflicts::ListService do it 'returns a falsey value when the MR has a missing ref after a force push' do merge_request = create_merge_request('conflict-resolvable') service = conflicts_service(merge_request) - allow_any_instance_of(Gitlab::GitalyClient::ConflictsService).to receive(:list_conflict_files).and_raise(GRPC::Unknown) + allow_next_instance_of(Gitlab::GitalyClient::ConflictsService) do |instance| + allow(instance).to receive(:list_conflict_files).and_raise(GRPC::Unknown) + end expect(service.can_be_resolved_in_ui?).to be_falsey end diff --git a/spec/services/merge_requests/create_from_issue_service_spec.rb b/spec/services/merge_requests/create_from_issue_service_spec.rb index 7145cfe7897b1110cb5b3c4dafa500195eece596..3d58ecdd8cd184bf4d6dafc6f578f2f6ba89833b 100644 --- a/spec/services/merge_requests/create_from_issue_service_spec.rb +++ b/spec/services/merge_requests/create_from_issue_service_spec.rb @@ -55,7 +55,9 @@ describe MergeRequests::CreateFromIssueService do end it 'creates the new_issue_branch system note when the branch could be created but the merge_request cannot be created', :sidekiq_might_not_need_inline do - expect_any_instance_of(MergeRequest).to receive(:valid?).at_least(:once).and_return(false) + expect_next_instance_of(MergeRequest) do |instance| + expect(instance).to receive(:valid?).at_least(:once).and_return(false) + end expect(SystemNoteService).to receive(:new_issue_branch).with(issue, project, user, issue.to_branch_name, branch_project: target_project) diff --git a/spec/services/merge_requests/create_pipeline_service_spec.rb b/spec/services/merge_requests/create_pipeline_service_spec.rb index 25f5c54a413f353c6344389255cd6491d4931a14..9eb287590612065ce14c3f6d861f2d7877c4b7db 100644 --- a/spec/services/merge_requests/create_pipeline_service_spec.rb +++ b/spec/services/merge_requests/create_pipeline_service_spec.rb @@ -3,8 +3,8 @@ require 'spec_helper' describe MergeRequests::CreatePipelineService do - set(:project) { create(:project, :repository) } - set(:user) { create(:user) } + let_it_be(:project) { create(:project, :repository) } + let_it_be(:user) { create(:user) } let(:service) { described_class.new(project, user, params) } let(:params) { {} } diff --git a/spec/services/merge_requests/get_urls_service_spec.rb b/spec/services/merge_requests/get_urls_service_spec.rb index dcb8c8080a1937f84698829c2ad89011bac208b9..bb8a1873dac3f69e1e18830347bbe8afd299a5e2 100644 --- a/spec/services/merge_requests/get_urls_service_spec.rb +++ b/spec/services/merge_requests/get_urls_service_spec.rb @@ -45,6 +45,13 @@ describe MergeRequests::GetUrlsService do end end + context 'when project is nil' do + let(:project) { nil } + let(:changes) { default_branch_changes } + + it_behaves_like 'no_merge_request_url' + end + context 'pushing to default branch' do let(:changes) { default_branch_changes } diff --git a/spec/services/merge_requests/merge_service_spec.rb b/spec/services/merge_requests/merge_service_spec.rb index 61c8103353ca69221f7da4c45f6da5be160e6534..fa1a8f60256fa759a92895c1626fe18cf14921c3 100644 --- a/spec/services/merge_requests/merge_service_spec.rb +++ b/spec/services/merge_requests/merge_service_spec.rb @@ -3,8 +3,8 @@ require 'spec_helper' describe MergeRequests::MergeService do - set(:user) { create(:user) } - set(:user2) { create(:user) } + let_it_be(:user) { create(:user) } + let_it_be(:user2) { create(:user) } let(:merge_request) { create(:merge_request, :simple, author: user2, assignees: [user2]) } let(:project) { merge_request.project } diff --git a/spec/services/merge_requests/merge_to_ref_service_spec.rb b/spec/services/merge_requests/merge_to_ref_service_spec.rb index 77e38f1eb4c51279485a075df40a2445ea39fc58..5c26e32bb22cb693c17f0edaf074bcdcfc95321b 100644 --- a/spec/services/merge_requests/merge_to_ref_service_spec.rb +++ b/spec/services/merge_requests/merge_to_ref_service_spec.rb @@ -67,7 +67,7 @@ describe MergeRequests::MergeToRefService do end end - set(:user) { create(:user) } + let_it_be(:user) { create(:user) } let(:merge_request) { create(:merge_request, :simple) } let(:project) { merge_request.project } @@ -214,7 +214,7 @@ describe MergeRequests::MergeToRefService do end describe 'cascading merge refs' do - set(:project) { create(:project, :repository) } + let_it_be(:project) { create(:project, :repository) } let(:params) { { commit_message: 'Cascading merge', first_parent_ref: first_parent_ref, target_ref: target_ref, sha: merge_request.diff_head_sha } } context 'when first merge happens' do diff --git a/spec/services/merge_requests/push_options_handler_service_spec.rb b/spec/services/merge_requests/push_options_handler_service_spec.rb index 7f9c47d86702c9f74538dde312fb1f53226841d0..420c8513c72039c492b3ba1b14867d616c775b88 100644 --- a/spec/services/merge_requests/push_options_handler_service_spec.rb +++ b/spec/services/merge_requests/push_options_handler_service_spec.rb @@ -714,9 +714,9 @@ describe MergeRequests::PushOptionsHandlerService do let(:exception) { StandardError.new('My standard error') } def run_service_with_exception - allow_any_instance_of( - MergeRequests::BuildService - ).to receive(:execute).and_raise(exception) + allow_next_instance_of(MergeRequests::BuildService) do |instance| + allow(instance).to receive(:execute).and_raise(exception) + end service.execute end @@ -766,9 +766,9 @@ describe MergeRequests::PushOptionsHandlerService do invalid_merge_request = MergeRequest.new invalid_merge_request.errors.add(:base, 'my error') - expect_any_instance_of( - MergeRequests::CreateService - ).to receive(:execute).and_return(invalid_merge_request) + expect_next_instance_of(MergeRequests::CreateService) do |instance| + expect(instance).to receive(:execute).and_return(invalid_merge_request) + end service.execute diff --git a/spec/services/merge_requests/rebase_service_spec.rb b/spec/services/merge_requests/rebase_service_spec.rb index 9c535664c26894aa5c2aa654a19f4ad128eec687..184f3f373397cd63a7e861c34787ef9f56dc6dc5 100644 --- a/spec/services/merge_requests/rebase_service_spec.rb +++ b/spec/services/merge_requests/rebase_service_spec.rb @@ -15,6 +15,7 @@ describe MergeRequests::RebaseService do end let(:project) { merge_request.project } let(:repository) { project.repository.raw } + let(:skip_ci) { false } subject(:service) { described_class.new(project, user, {}) } @@ -115,7 +116,7 @@ describe MergeRequests::RebaseService do context 'valid params' do shared_examples_for 'a service that can execute a successful rebase' do before do - service.execute(merge_request) + service.execute(merge_request, skip_ci: skip_ci) end it 'rebases source branch' do @@ -155,6 +156,12 @@ describe MergeRequests::RebaseService do it_behaves_like 'a service that can execute a successful rebase' end + context 'when skip_ci flag is set' do + let(:skip_ci) { true } + + it_behaves_like 'a service that can execute a successful rebase' + end + context 'fork' do describe 'successful fork rebase' do let(:forked_project) do diff --git a/spec/services/metrics/dashboard/clone_dashboard_service_spec.rb b/spec/services/metrics/dashboard/clone_dashboard_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..274d594fd680755bd57bcf25870f0f2af9e15a65 --- /dev/null +++ b/spec/services/metrics/dashboard/clone_dashboard_service_spec.rb @@ -0,0 +1,197 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Metrics::Dashboard::CloneDashboardService, :use_clean_rails_memory_store_caching do + include MetricsDashboardHelpers + + set(:user) { create(:user) } + set(:project) { create(:project, :repository) } + set(:environment) { create(:environment, project: project) } + + describe '#execute' do + subject(:service_call) { described_class.new(project, user, params).execute } + + let(:commit_message) { 'test' } + let(:branch) { "dashboard_new_branch" } + let(:dashboard) { 'config/prometheus/common_metrics.yml' } + let(:file_name) { 'custom_dashboard.yml' } + let(:params) do + { + dashboard: dashboard, + file_name: file_name, + commit_message: commit_message, + branch: branch + } + end + + let(:dashboard_attrs) do + { + commit_message: commit_message, + branch_name: branch, + start_branch: project.default_branch, + encoding: 'text', + file_path: ".gitlab/dashboards/#{file_name}", + file_content: File.read(dashboard) + } + end + + context 'user does not have push right to repository' do + it_behaves_like 'misconfigured dashboard service response', :forbidden, %q(You can't commit to this project) + end + + context 'with rights to push to the repository' do + before do + project.add_maintainer(user) + end + + context 'wrong target file extension' do + let(:file_name) { 'custom_dashboard.txt' } + + it_behaves_like 'misconfigured dashboard service response', :bad_request, 'The file name should have a .yml extension' + end + + context 'wrong source dashboard file' do + let(:dashboard) { 'config/prometheus/common_metrics_123.yml' } + + it_behaves_like 'misconfigured dashboard service response', :not_found, 'Not found.' + end + + context 'path traversal attack attempt' do + let(:dashboard) { 'config/prometheus/../database.yml' } + + it_behaves_like 'misconfigured dashboard service response', :not_found, 'Not found.' + end + + context 'path traversal attack attempt on target file' do + let(:file_name) { '../../custom_dashboard.yml' } + let(:dashboard_attrs) do + { + commit_message: commit_message, + branch_name: branch, + start_branch: project.default_branch, + encoding: 'text', + file_path: ".gitlab/dashboards/custom_dashboard.yml", + file_content: File.read(dashboard) + } + end + + it 'strips target file name to safe value', :aggregate_failures do + service_instance = instance_double(::Files::CreateService) + expect(::Files::CreateService).to receive(:new).with(project, user, dashboard_attrs).and_return(service_instance) + expect(service_instance).to receive(:execute).and_return(status: :success) + + service_call + end + end + + context 'valid parameters' do + it 'delegates commit creation to Files::CreateService', :aggregate_failures do + service_instance = instance_double(::Files::CreateService) + expect(::Files::CreateService).to receive(:new).with(project, user, dashboard_attrs).and_return(service_instance) + expect(service_instance).to receive(:execute).and_return(status: :success) + + service_call + end + + context 'selected branch already exists' do + let(:branch) { 'existing_branch' } + + before do + project.repository.add_branch(user, branch, 'master') + end + + it_behaves_like 'misconfigured dashboard service response', :bad_request, "There was an error creating the dashboard, branch named: existing_branch already exists." + + # temporary not available function for first iteration + # follow up issue https://gitlab.com/gitlab-org/gitlab/issues/196237 which + # require this feature + # it 'pass correct params to Files::CreateService', :aggregate_failures do + # project.repository.add_branch(user, branch, 'master') + # + # service_instance = instance_double(::Files::CreateService) + # expect(::Files::CreateService).to receive(:new).with(project, user, dashboard_attrs).and_return(service_instance) + # expect(service_instance).to receive(:execute).and_return(status: :success) + # + # service_call + # end + end + + context 'blank branch name' do + let(:branch) { '' } + + it_behaves_like 'misconfigured dashboard service response', :bad_request, 'There was an error creating the dashboard, branch name is invalid.' + end + + context 'dashboard file already exists' do + let(:branch) { 'custom_dashboard' } + + before do + Files::CreateService.new( + project, + user, + commit_message: 'Create custom dashboard custom_dashboard.yml', + branch_name: 'master', + start_branch: 'master', + file_path: ".gitlab/dashboards/custom_dashboard.yml", + file_content: File.read('config/prometheus/common_metrics.yml') + ).execute + end + + it_behaves_like 'misconfigured dashboard service response', :bad_request, "A file with 'custom_dashboard.yml' already exists in custom_dashboard branch" + end + + it 'extends dashboard template path to absolute url' do + allow(::Files::CreateService).to receive(:new).and_return(double(execute: { status: :success })) + + expect(File).to receive(:read).with(Rails.root.join('config/prometheus/common_metrics.yml')).and_return('') + + service_call + end + + context 'Files::CreateService success' do + before do + allow(::Files::CreateService).to receive(:new).and_return(double(execute: { status: :success })) + end + + it 'clears dashboards cache' do + expect(project.repository).to receive(:refresh_method_caches).with([:metrics_dashboard]) + + service_call + end + + it 'returns success', :aggregate_failures do + result = service_call + dashboard_details = { + path: '.gitlab/dashboards/custom_dashboard.yml', + display_name: 'custom_dashboard.yml', + default: false, + system_dashboard: false + } + + expect(result[:status]).to be :success + expect(result[:http_status]).to be :created + expect(result[:dashboard]).to match dashboard_details + end + end + + context 'Files::CreateService fails' do + before do + allow(::Files::CreateService).to receive(:new).and_return(double(execute: { status: :error })) + end + + it 'does NOT clear dashboards cache' do + expect(project.repository).not_to receive(:refresh_method_caches) + + service_call + end + + it 'returns error' do + result = service_call + expect(result[:status]).to be :error + end + end + end + end + end +end diff --git a/spec/services/metrics/dashboard/custom_metric_embed_service_spec.rb b/spec/services/metrics/dashboard/custom_metric_embed_service_spec.rb index 53b7497ae2128a73415b611576f9e491d008ad64..744693dad151f8db9b2d969cf250dda92bed4319 100644 --- a/spec/services/metrics/dashboard/custom_metric_embed_service_spec.rb +++ b/spec/services/metrics/dashboard/custom_metric_embed_service_spec.rb @@ -5,9 +5,9 @@ require 'spec_helper' describe Metrics::Dashboard::CustomMetricEmbedService do include MetricsDashboardHelpers - set(:project) { build(:project) } - set(:user) { create(:user) } - set(:environment) { create(:environment, project: project) } + let_it_be(:project, reload: true) { build(:project) } + let_it_be(:user) { create(:user) } + let_it_be(:environment) { create(:environment, project: project) } before do project.add_maintainer(user) diff --git a/spec/services/metrics/dashboard/default_embed_service_spec.rb b/spec/services/metrics/dashboard/default_embed_service_spec.rb index 803b9a93be7030b2f3624b79ac24a96741e4f06a..741a9644905297d926365c8e1a1fe99dc394aea9 100644 --- a/spec/services/metrics/dashboard/default_embed_service_spec.rb +++ b/spec/services/metrics/dashboard/default_embed_service_spec.rb @@ -5,9 +5,9 @@ require 'spec_helper' describe Metrics::Dashboard::DefaultEmbedService, :use_clean_rails_memory_store_caching do include MetricsDashboardHelpers - set(:project) { build(:project) } - set(:user) { create(:user) } - set(:environment) { create(:environment, project: project) } + let_it_be(:project) { build(:project) } + let_it_be(:user) { create(:user) } + let_it_be(:environment) { create(:environment, project: project) } before do project.add_maintainer(user) diff --git a/spec/services/metrics/dashboard/dynamic_embed_service_spec.rb b/spec/services/metrics/dashboard/dynamic_embed_service_spec.rb index a0f7315f75054034fef8c0e032d7c496b36e0898..c1ce9818f2151eead6917054a88614d7c9783434 100644 --- a/spec/services/metrics/dashboard/dynamic_embed_service_spec.rb +++ b/spec/services/metrics/dashboard/dynamic_embed_service_spec.rb @@ -5,9 +5,9 @@ require 'spec_helper' describe Metrics::Dashboard::DynamicEmbedService, :use_clean_rails_memory_store_caching do include MetricsDashboardHelpers - set(:project) { build(:project) } - set(:user) { create(:user) } - set(:environment) { create(:environment, project: project) } + let_it_be(:project) { build(:project) } + let_it_be(:user) { create(:user) } + let_it_be(:environment) { create(:environment, project: project) } before do project.add_maintainer(user) diff --git a/spec/services/metrics/dashboard/project_dashboard_service_spec.rb b/spec/services/metrics/dashboard/project_dashboard_service_spec.rb index ab7a7b978615614956dfd5af9f02fd79ab0f4642..cba8ef2ec98e9017beeacf57c3d2b0dbf61edfdd 100644 --- a/spec/services/metrics/dashboard/project_dashboard_service_spec.rb +++ b/spec/services/metrics/dashboard/project_dashboard_service_spec.rb @@ -5,9 +5,9 @@ require 'spec_helper' describe Metrics::Dashboard::ProjectDashboardService, :use_clean_rails_memory_store_caching do include MetricsDashboardHelpers - set(:user) { create(:user) } - set(:project) { create(:project) } - set(:environment) { create(:environment, project: project) } + let_it_be(:user) { create(:user) } + let_it_be(:project) { create(:project) } + let_it_be(:environment) { create(:environment, project: project) } before do project.add_maintainer(user) diff --git a/spec/services/metrics/dashboard/system_dashboard_service_spec.rb b/spec/services/metrics/dashboard/system_dashboard_service_spec.rb index 95c5a1479a4e41d1c9f1b1fc0f74aa0c1560f23f..cc9f711c6117b851e7846da239029e7970e16caf 100644 --- a/spec/services/metrics/dashboard/system_dashboard_service_spec.rb +++ b/spec/services/metrics/dashboard/system_dashboard_service_spec.rb @@ -5,9 +5,9 @@ require 'spec_helper' describe Metrics::Dashboard::SystemDashboardService, :use_clean_rails_memory_store_caching do include MetricsDashboardHelpers - set(:user) { create(:user) } - set(:project) { create(:project) } - set(:environment) { create(:environment, project: project) } + let_it_be(:user) { create(:user) } + let_it_be(:project) { create(:project) } + let_it_be(:environment) { create(:environment, project: project) } before do project.add_maintainer(user) diff --git a/spec/services/metrics/sample_metrics_service_spec.rb b/spec/services/metrics/sample_metrics_service_spec.rb index 8574674ebc4c64d336267cf8fdac8e904682604e..3b4f7cb8062252a5201b0c3eb1779df1897e42c9 100644 --- a/spec/services/metrics/sample_metrics_service_spec.rb +++ b/spec/services/metrics/sample_metrics_service_spec.rb @@ -4,7 +4,10 @@ require 'spec_helper' describe Metrics::SampleMetricsService do describe 'query' do - subject { described_class.new(identifier).query } + let(:range_start) { '2019-12-02T23:31:45.000Z' } + let(:range_end) { '2019-12-03T00:01:45.000Z' } + + subject { described_class.new(identifier, range_start: range_start, range_end: range_end).query } context 'when the file is not found' do let(:identifier) { nil } @@ -26,10 +29,10 @@ describe Metrics::SampleMetricsService do FileUtils.rm(destination) end - subject { described_class.new(identifier).query } + subject { described_class.new(identifier, range_start: range_start, range_end: range_end).query } it 'loads data from the sample file correctly' do - expect(subject).to eq(YAML.load_file(source)) + expect(subject).to eq(YAML.load_file(source)[30]) end end diff --git a/spec/services/milestones/promote_service_spec.rb b/spec/services/milestones/promote_service_spec.rb index 22c7e9dde309f6db6bc18a11b0396cf2e1f37545..fa893b86cdbcdf361f8c85e6217361c8043d625a 100644 --- a/spec/services/milestones/promote_service_spec.rb +++ b/spec/services/milestones/promote_service_spec.rb @@ -31,7 +31,9 @@ describe Milestones::PromoteService do it 'does not promote milestone and update issuables if promoted milestone is not valid' do issue = create(:issue, milestone: milestone, project: project) merge_request = create(:merge_request, milestone: milestone, source_project: project) - allow_any_instance_of(Milestone).to receive(:valid?).and_return(false) + allow_next_instance_of(Milestone) do |instance| + allow(instance).to receive(:valid?).and_return(false) + end expect { service.execute(milestone) }.to raise_error(described_class::PromoteMilestoneError) diff --git a/spec/services/milestones/transfer_service_spec.rb b/spec/services/milestones/transfer_service_spec.rb index b3d41eb0763e5ac01e811e5fc00095f09d45faf6..711969ce5043e81e8661ae484db11eb8cd277a5c 100644 --- a/spec/services/milestones/transfer_service_spec.rb +++ b/spec/services/milestones/transfer_service_spec.rb @@ -71,7 +71,9 @@ describe Milestones::TransferService do context 'when find_or_create_milestone returns nil' do before do - allow_any_instance_of(Milestones::FindOrCreateService).to receive(:execute).and_return(nil) + allow_next_instance_of(Milestones::FindOrCreateService) do |instance| + allow(instance).to receive(:execute).and_return(nil) + end end it 'removes issues group milestone' do diff --git a/spec/services/namespaces/statistics_refresher_service_spec.rb b/spec/services/namespaces/statistics_refresher_service_spec.rb index 9d42e917efe00f11fade02bb07a73544c2dd1464..1fa0a794edd2f717f60569f675fc6063df90a066 100644 --- a/spec/services/namespaces/statistics_refresher_service_spec.rb +++ b/spec/services/namespaces/statistics_refresher_service_spec.rb @@ -17,7 +17,9 @@ describe Namespaces::StatisticsRefresherService, '#execute' do end it 'recalculate the namespace statistics' do - expect_any_instance_of(Namespace::RootStorageStatistics).to receive(:recalculate!).once + expect_next_instance_of(Namespace::RootStorageStatistics) do |instance| + expect(instance).to receive(:recalculate!).once + end service.execute(group) end @@ -45,8 +47,9 @@ describe Namespaces::StatisticsRefresherService, '#execute' do context 'when something goes wrong' do before do - allow_any_instance_of(Namespace::RootStorageStatistics) - .to receive(:recalculate!).and_raise(ActiveRecord::ActiveRecordError) + allow_next_instance_of(Namespace::RootStorageStatistics) do |instance| + allow(instance).to receive(:recalculate!).and_raise(ActiveRecord::ActiveRecordError) + end end it 'raises RefreshError' do diff --git a/spec/services/notes/create_service_spec.rb b/spec/services/notes/create_service_spec.rb index 8fd03428eb26735ec510213507d64bb84f14435a..c5e2fe8de127b34fdaf36e22fedab2777884c23f 100644 --- a/spec/services/notes/create_service_spec.rb +++ b/spec/services/notes/create_service_spec.rb @@ -3,9 +3,9 @@ require 'spec_helper' describe Notes::CreateService do - set(:project) { create(:project, :repository) } - set(:issue) { create(:issue, project: project) } - set(:user) { create(:user) } + let_it_be(:project) { create(:project, :repository) } + let_it_be(:issue) { create(:issue, project: project) } + let_it_be(:user) { create(:user) } let(:opts) do { note: 'Awesome comment', noteable_type: 'Issue', noteable_id: issue.id } end @@ -216,12 +216,12 @@ describe Notes::CreateService do context 'note with commands' do context 'all quick actions' do - set(:milestone) { create(:milestone, project: project, title: "sprint") } - set(:bug_label) { create(:label, project: project, title: 'bug') } - set(:to_be_copied_label) { create(:label, project: project, title: 'to be copied') } - set(:feature_label) { create(:label, project: project, title: 'feature') } - set(:issue) { create(:issue, project: project, labels: [bug_label], due_date: '2019-01-01') } - set(:issue_2) { create(:issue, project: project, labels: [bug_label, to_be_copied_label]) } + let_it_be(:milestone) { create(:milestone, project: project, title: "sprint") } + let_it_be(:bug_label) { create(:label, project: project, title: 'bug') } + let_it_be(:to_be_copied_label) { create(:label, project: project, title: 'to be copied') } + let_it_be(:feature_label) { create(:label, project: project, title: 'feature') } + let_it_be(:issue, reload: true) { create(:issue, project: project, labels: [bug_label], due_date: '2019-01-01') } + let_it_be(:issue_2) { create(:issue, project: project, labels: [bug_label, to_be_copied_label]) } context 'for issues' do let(:issuable) { issue } @@ -272,7 +272,7 @@ describe Notes::CreateService do end context 'for merge requests' do - set(:merge_request) { create(:merge_request, source_project: project, labels: [bug_label]) } + let_it_be(:merge_request) { create(:merge_request, source_project: project, labels: [bug_label]) } let(:issuable) { merge_request } let(:note_params) { opts.merge(noteable_type: 'MergeRequest', noteable_id: merge_request.id) } let(:merge_request_quick_actions) do diff --git a/spec/services/notes/destroy_service_spec.rb b/spec/services/notes/destroy_service_spec.rb index 9faf1299ef2caf8f8e970e5993f2652440c20a6d..258e5c6826519bb2a0f96417ecaede5b6277fa4a 100644 --- a/spec/services/notes/destroy_service_spec.rb +++ b/spec/services/notes/destroy_service_spec.rb @@ -3,8 +3,8 @@ require 'spec_helper' describe Notes::DestroyService do - set(:project) { create(:project, :public) } - set(:issue) { create(:issue, project: project) } + let_it_be(:project) { create(:project, :public) } + let_it_be(:issue) { create(:issue, project: project) } let(:user) { issue.author } describe '#execute' do diff --git a/spec/services/notes/resolve_service_spec.rb b/spec/services/notes/resolve_service_spec.rb index 3f82e1dbdc0bf2e89dde166ee452e77b5914f8a3..c98384c226ed324f29395a9b9ccb2697e10cf9f4 100644 --- a/spec/services/notes/resolve_service_spec.rb +++ b/spec/services/notes/resolve_service_spec.rb @@ -17,7 +17,9 @@ describe Notes::ResolveService do end it "sends notifications if all discussions are resolved" do - expect_any_instance_of(MergeRequests::ResolvedDiscussionNotificationService).to receive(:execute).with(merge_request) + expect_next_instance_of(MergeRequests::ResolvedDiscussionNotificationService) do |instance| + expect(instance).to receive(:execute).with(merge_request) + end described_class.new(merge_request.project, user).execute(note) end diff --git a/spec/services/notification_service_spec.rb b/spec/services/notification_service_spec.rb index b80f75c70e6693dfa4bd001d79cf8c1478aa1803..80b8d36aa07d086dc7eb8b18bfc6306be156eecb 100644 --- a/spec/services/notification_service_spec.rb +++ b/spec/services/notification_service_spec.rb @@ -155,7 +155,7 @@ describe NotificationService, :mailer do describe '#async' do let(:async) { notification.async } - set(:key) { create(:personal_key) } + let_it_be(:key) { create(:personal_key) } it 'returns an Async object with the correct parent' do expect(async).to be_a(described_class::Async) @@ -2508,14 +2508,14 @@ describe NotificationService, :mailer do end describe 'Pages domains' do - set(:project) { create(:project) } - set(:domain) { create(:pages_domain, project: project) } - set(:u_blocked) { create(:user, :blocked) } - set(:u_silence) { create_user_with_notification(:disabled, 'silent', project) } - set(:u_owner) { project.owner } - set(:u_maintainer1) { create(:user) } - set(:u_maintainer2) { create(:user) } - set(:u_developer) { create(:user) } + let_it_be(:project, reload: true) { create(:project) } + let_it_be(:domain, reload: true) { create(:pages_domain, project: project) } + let_it_be(:u_blocked) { create(:user, :blocked) } + let_it_be(:u_silence) { create_user_with_notification(:disabled, 'silent', project) } + let_it_be(:u_owner) { project.owner } + let_it_be(:u_maintainer1) { create(:user) } + let_it_be(:u_maintainer2) { create(:user) } + let_it_be(:u_developer) { create(:user) } before do project.add_maintainer(u_blocked) @@ -2707,7 +2707,7 @@ describe NotificationService, :mailer do # User to be participant by default # This user does not contain any record in notification settings table # It should be treated with a :participating notification_level - @u_lazy_participant = create(:user, username: 'lazy-participant') + @u_lazy_participant = create(:user, username: 'lazy-participant') @u_guest_watcher = create_user_with_notification(:watch, 'guest_watching') @u_guest_custom = create_user_with_notification(:custom, 'guest_custom') diff --git a/spec/services/pages_domains/create_acme_order_service_spec.rb b/spec/services/pages_domains/create_acme_order_service_spec.rb index d59aa9b979ee80c8959e0785da838b87a51bde22..154b3fd56009a944f38008b511bbd3e1d9df1c8a 100644 --- a/spec/services/pages_domains/create_acme_order_service_spec.rb +++ b/spec/services/pages_domains/create_acme_order_service_spec.rb @@ -45,12 +45,34 @@ describe PagesDomains::CreateAcmeOrderService do expect { OpenSSL::PKey::RSA.new(saved_order.private_key) }.not_to raise_error end - it 'properly saves order attributes' do + it 'properly saves order url' do service.execute saved_order = PagesDomainAcmeOrder.last expect(saved_order.url).to eq(order_double.url) - expect(saved_order.expires_at).to be_like_time(order_double.expires) + end + + context 'when order expires in 2 days' do + it 'sets expiration time in 2 hours' do + Timecop.freeze do + service.execute + + saved_order = PagesDomainAcmeOrder.last + expect(saved_order.expires_at).to be_like_time(2.hours.from_now) + end + end + end + + context 'when order expires in an hour' do + it 'sets expiration time accordingly to order' do + Timecop.freeze do + allow(order_double).to receive(:expires).and_return(1.hour.from_now) + service.execute + + saved_order = PagesDomainAcmeOrder.last + expect(saved_order.expires_at).to be_like_time(1.hour.from_now) + end + end end it 'properly saves challenge attributes' do diff --git a/spec/services/pages_domains/obtain_lets_encrypt_certificate_service_spec.rb b/spec/services/pages_domains/obtain_lets_encrypt_certificate_service_spec.rb index af79a42b6119b19d2ab0120ea3ebe7e8b604ee07..9832ba9552407f99373c119efbea613968a2bc39 100644 --- a/spec/services/pages_domains/obtain_lets_encrypt_certificate_service_spec.rb +++ b/spec/services/pages_domains/obtain_lets_encrypt_certificate_service_spec.rb @@ -32,9 +32,9 @@ describe PagesDomains::ObtainLetsEncryptCertificateService do def stub_lets_encrypt_order(url, status) order = ::Gitlab::LetsEncrypt::Order.new(acme_order_double(status: status)) - allow_any_instance_of(::Gitlab::LetsEncrypt::Client).to( - receive(:load_order).with(url).and_return(order) - ) + allow_next_instance_of(::Gitlab::LetsEncrypt::Client) do |instance| + allow(instance).to receive(:load_order).with(url).and_return(order) + end order end diff --git a/spec/services/projects/container_repository/cleanup_tags_service_spec.rb b/spec/services/projects/container_repository/cleanup_tags_service_spec.rb index 14772d172e81c3574f9d3b1311a2bdb3d2e01ea4..78b969c8a0eab52ea18cce17d696448012ac6a8f 100644 --- a/spec/services/projects/container_repository/cleanup_tags_service_spec.rb +++ b/spec/services/projects/container_repository/cleanup_tags_service_spec.rb @@ -3,9 +3,9 @@ require 'spec_helper' describe Projects::ContainerRepository::CleanupTagsService do - set(:user) { create(:user) } - set(:project) { create(:project, :private) } - set(:repository) { create(:container_repository, :root, project: project) } + let_it_be(:user) { create(:user) } + let_it_be(:project) { create(:project, :private) } + let_it_be(:repository) { create(:container_repository, :root, project: project) } let(:service) { described_class.new(project, user, params) } diff --git a/spec/services/projects/container_repository/delete_tags_service_spec.rb b/spec/services/projects/container_repository/delete_tags_service_spec.rb index 7ceb02c9cf8e4287400b653f9ba5bbf364dc834a..decbbb7597fa8cb3c1ae4400e969553ea73a01e9 100644 --- a/spec/services/projects/container_repository/delete_tags_service_spec.rb +++ b/spec/services/projects/container_repository/delete_tags_service_spec.rb @@ -3,9 +3,9 @@ require 'spec_helper' describe Projects::ContainerRepository::DeleteTagsService do - set(:user) { create(:user) } - set(:project) { create(:project, :private) } - set(:repository) { create(:container_repository, :root, project: project) } + let_it_be(:user) { create(:user) } + let_it_be(:project) { create(:project, :private) } + let_it_be(:repository) { create(:container_repository, :root, project: project) } let(:params) { { tags: tags } } let(:service) { described_class.new(project, user, params) } diff --git a/spec/services/projects/container_repository/destroy_service_spec.rb b/spec/services/projects/container_repository/destroy_service_spec.rb index affcc66d2bb2c2803d33cf4c9bac18da043db27e..cc8fd2716e17e3d6d3c5de815ecf6158de455e6f 100644 --- a/spec/services/projects/container_repository/destroy_service_spec.rb +++ b/spec/services/projects/container_repository/destroy_service_spec.rb @@ -3,8 +3,8 @@ require 'spec_helper' describe Projects::ContainerRepository::DestroyService do - set(:user) { create(:user) } - set(:project) { create(:project, :private) } + let_it_be(:user) { create(:user) } + let_it_be(:project) { create(:project, :private) } subject { described_class.new(project, user) } diff --git a/spec/services/projects/create_service_spec.rb b/spec/services/projects/create_service_spec.rb index 814bf912c8ca4b8555a93ba29353b98349eceac6..bce3f72a2877e65953c41b8ad7625608fc0821e9 100644 --- a/spec/services/projects/create_service_spec.rb +++ b/spec/services/projects/create_service_spec.rb @@ -247,7 +247,9 @@ describe Projects::CreateService, '#execute' do context 'repository creation' do it 'synchronously creates the repository' do - expect_any_instance_of(Project).to receive(:create_repository) + expect_next_instance_of(Project) do |instance| + expect(instance).to receive(:create_repository) + end project = create_project(user, opts) expect(project).to be_valid diff --git a/spec/services/projects/detect_repository_languages_service_spec.rb b/spec/services/projects/detect_repository_languages_service_spec.rb index df5eed18ac0d89f5f6639401c486bc90a2650820..76600b0e77c1126e5470495daf212ec9403c4d85 100644 --- a/spec/services/projects/detect_repository_languages_service_spec.rb +++ b/spec/services/projects/detect_repository_languages_service_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' describe Projects::DetectRepositoryLanguagesService, :clean_gitlab_redis_shared_state do - set(:project) { create(:project, :repository) } + let_it_be(:project, reload: true) { create(:project, :repository) } subject { described_class.new(project) } @@ -51,7 +51,7 @@ describe Projects::DetectRepositoryLanguagesService, :clean_gitlab_redis_shared_ end context 'when no repository exists' do - set(:project) { create(:project) } + let_it_be(:project) { create(:project) } it 'has no languages' do expect(subject.execute).to be_empty diff --git a/spec/services/projects/fork_service_spec.rb b/spec/services/projects/fork_service_spec.rb index fc01c93b5cf627b12c100516b08bbc8ec9184968..e7b904fcd601c765b26ffe84033c6cea8ed37d57 100644 --- a/spec/services/projects/fork_service_spec.rb +++ b/spec/services/projects/fork_service_spec.rb @@ -224,6 +224,19 @@ describe Projects::ForkService do end end end + + context 'when forking is disabled' do + before do + @from_project.project_feature.update_attribute( + :forking_access_level, ProjectFeature::DISABLED) + end + + it 'fails' do + to_project = fork_project(@from_project, @to_user, namespace: @to_user.namespace) + + expect(to_project.errors[:forked_from_project_id]).to eq(['is forbidden']) + end + end end describe 'fork to namespace' do diff --git a/spec/services/projects/gitlab_projects_import_service_spec.rb b/spec/services/projects/gitlab_projects_import_service_spec.rb index 78580bfa60499425999f5ce46b3a8c53068fb518..1662d4577aa94489b48841b2d8d82545a8d67c1e 100644 --- a/spec/services/projects/gitlab_projects_import_service_spec.rb +++ b/spec/services/projects/gitlab_projects_import_service_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' describe Projects::GitlabProjectsImportService do - set(:namespace) { create(:namespace) } + let_it_be(:namespace) { create(:namespace) } let(:path) { 'test-path' } let(:file) { fixture_file_upload('spec/fixtures/project_export.tar.gz') } let(:overwrite) { false } diff --git a/spec/services/projects/housekeeping_service_spec.rb b/spec/services/projects/housekeeping_service_spec.rb index c99054d9fd5b08dbda690b2d4e7ddf561195f773..60804a8dba608633c3f98e62d3c2631817f33fe2 100644 --- a/spec/services/projects/housekeeping_service_spec.rb +++ b/spec/services/projects/housekeeping_service_spec.rb @@ -5,7 +5,7 @@ require 'spec_helper' describe Projects::HousekeepingService do subject { described_class.new(project) } - set(:project) { create(:project, :repository) } + let_it_be(:project) { create(:project, :repository) } before do project.reset_pushes_since_gc diff --git a/spec/services/projects/import_export/export_service_spec.rb b/spec/services/projects/import_export/export_service_spec.rb index a557e61da787f87ec91071257de951e7f14acb09..c7ac07fc52431bdc074c030f9b3facc6afa76dad 100644 --- a/spec/services/projects/import_export/export_service_spec.rb +++ b/spec/services/projects/import_export/export_service_spec.rb @@ -94,7 +94,9 @@ describe Projects::ImportExport::ExportService do end it 'notifies the user' do - expect_any_instance_of(NotificationService).to receive(:project_not_exported) + expect_next_instance_of(NotificationService) do |instance| + expect(instance).to receive(:project_not_exported) + end end it 'notifies logger' do @@ -122,7 +124,9 @@ describe Projects::ImportExport::ExportService do end it 'notifies the user' do - expect_any_instance_of(NotificationService).to receive(:project_not_exported) + expect_next_instance_of(NotificationService) do |instance| + expect(instance).to receive(:project_not_exported) + end end it 'notifies logger' do diff --git a/spec/services/projects/lfs_pointers/lfs_import_service_spec.rb b/spec/services/projects/lfs_pointers/lfs_import_service_spec.rb index 7ca20a6d751c74b81644cab89b96e0b7b51353c8..016028a96bf304bfb9594de5aaa4b30b8ddd6052 100644 --- a/spec/services/projects/lfs_pointers/lfs_import_service_spec.rb +++ b/spec/services/projects/lfs_pointers/lfs_import_service_spec.rb @@ -16,7 +16,9 @@ describe Projects::LfsPointers::LfsImportService do it 'downloads lfs objects' do service = double - expect_any_instance_of(Projects::LfsPointers::LfsObjectDownloadListService).to receive(:execute).and_return(oid_download_links) + expect_next_instance_of(Projects::LfsPointers::LfsObjectDownloadListService) do |instance| + expect(instance).to receive(:execute).and_return(oid_download_links) + end expect(Projects::LfsPointers::LfsDownloadService).to receive(:new).and_return(service).twice expect(service).to receive(:execute).twice @@ -27,7 +29,9 @@ describe Projects::LfsPointers::LfsImportService do context 'when no downloadable lfs object links' do it 'does not call LfsDownloadService' do - expect_any_instance_of(Projects::LfsPointers::LfsObjectDownloadListService).to receive(:execute).and_return({}) + expect_next_instance_of(Projects::LfsPointers::LfsObjectDownloadListService) do |instance| + expect(instance).to receive(:execute).and_return({}) + end expect(Projects::LfsPointers::LfsDownloadService).not_to receive(:new) result = subject.execute @@ -39,7 +43,9 @@ describe Projects::LfsPointers::LfsImportService do context 'when an exception is raised' do it 'returns error' do error_message = "error message" - expect_any_instance_of(Projects::LfsPointers::LfsObjectDownloadListService).to receive(:execute).and_raise(StandardError, error_message) + expect_next_instance_of(Projects::LfsPointers::LfsObjectDownloadListService) do |instance| + expect(instance).to receive(:execute).and_raise(StandardError, error_message) + end result = subject.execute diff --git a/spec/services/projects/open_merge_requests_count_service_spec.rb b/spec/services/projects/open_merge_requests_count_service_spec.rb index f9fff4cbd4ca2cd45849f0a931bb3c1fdb3ae1e0..7d848f9f2c3a309246c7602e6d7fbf7dc5137c00 100644 --- a/spec/services/projects/open_merge_requests_count_service_spec.rb +++ b/spec/services/projects/open_merge_requests_count_service_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' describe Projects::OpenMergeRequestsCountService, :use_clean_rails_memory_store_caching do - set(:project) { create(:project) } + let_it_be(:project) { create(:project) } subject { described_class.new(project) } diff --git a/spec/services/projects/operations/update_service_spec.rb b/spec/services/projects/operations/update_service_spec.rb index 81d59a98b9b9e12c47ecff4851775c4b83d14270..93cd5c43e86177a1c2f6682bf83576200384409e 100644 --- a/spec/services/projects/operations/update_service_spec.rb +++ b/spec/services/projects/operations/update_service_spec.rb @@ -3,8 +3,8 @@ require 'spec_helper' describe Projects::Operations::UpdateService do - set(:user) { create(:user) } - set(:project) { create(:project) } + let_it_be(:user) { create(:user) } + let_it_be(:project, reload: true) { create(:project) } let(:result) { subject.execute } diff --git a/spec/services/projects/participants_service_spec.rb b/spec/services/projects/participants_service_spec.rb index 239d28557eeff33577a6de2b0ca7fed584afbd99..6eaf7a71b23b5a0fdd557429ae40d58710fe827a 100644 --- a/spec/services/projects/participants_service_spec.rb +++ b/spec/services/projects/participants_service_spec.rb @@ -4,8 +4,8 @@ require 'spec_helper' describe Projects::ParticipantsService do describe '#groups' do - set(:user) { create(:user) } - set(:project) { create(:project, :public) } + let_it_be(:user) { create(:user) } + let_it_be(:project) { create(:project, :public) } let(:service) { described_class.new(project, user) } it 'avoids N+1 queries' do @@ -62,10 +62,10 @@ describe Projects::ParticipantsService do subject(:usernames) { service.project_members.map { |member| member[:username] } } context 'when there is a project in group namespace' do - set(:public_group) { create(:group, :public) } - set(:public_project) { create(:project, :public, namespace: public_group)} + let_it_be(:public_group) { create(:group, :public) } + let_it_be(:public_project) { create(:project, :public, namespace: public_group)} - set(:public_group_owner) { create(:user) } + let_it_be(:public_group_owner) { create(:user) } let(:service) { described_class.new(public_project, create(:user)) } @@ -79,18 +79,18 @@ describe Projects::ParticipantsService do end context 'when there is a private group and a public project' do - set(:public_group) { create(:group, :public) } - set(:private_group) { create(:group, :private, :nested) } - set(:public_project) { create(:project, :public, namespace: public_group)} + let_it_be(:public_group) { create(:group, :public) } + let_it_be(:private_group) { create(:group, :private, :nested) } + let_it_be(:public_project) { create(:project, :public, namespace: public_group)} - set(:project_issue) { create(:issue, project: public_project)} + let_it_be(:project_issue) { create(:issue, project: public_project)} - set(:public_group_owner) { create(:user) } - set(:private_group_member) { create(:user) } - set(:public_project_maintainer) { create(:user) } - set(:private_group_owner) { create(:user) } + let_it_be(:public_group_owner) { create(:user) } + let_it_be(:private_group_member) { create(:user) } + let_it_be(:public_project_maintainer) { create(:user) } + let_it_be(:private_group_owner) { create(:user) } - set(:group_ancestor_owner) { create(:user) } + let_it_be(:group_ancestor_owner) { create(:user) } before(:context) do public_group.add_owner public_group_owner diff --git a/spec/services/projects/update_pages_service_spec.rb b/spec/services/projects/update_pages_service_spec.rb index fe92b53cd91c6b11f4a4cab65feb07aa83050310..714256d9b08f65e1835225464a5678b0676b86cc 100644 --- a/spec/services/projects/update_pages_service_spec.rb +++ b/spec/services/projects/update_pages_service_spec.rb @@ -3,9 +3,9 @@ require "spec_helper" describe Projects::UpdatePagesService do - set(:project) { create(:project, :repository) } - set(:pipeline) { create(:ci_pipeline, project: project, sha: project.commit('HEAD').sha) } - set(:build) { create(:ci_build, pipeline: pipeline, ref: 'HEAD') } + let_it_be(:project, refind: true) { create(:project, :repository) } + let_it_be(:pipeline) { create(:ci_pipeline, project: project, sha: project.commit('HEAD').sha) } + let_it_be(:build) { create(:ci_build, pipeline: pipeline, ref: 'HEAD') } let(:invalid_file) { fixture_file_upload('spec/fixtures/dk.png') } let(:file) { fixture_file_upload("spec/fixtures/pages.zip") } @@ -110,8 +110,9 @@ describe Projects::UpdatePagesService do context 'when timeout happens by DNS error' do before do - allow_any_instance_of(described_class) - .to receive(:extract_zip_archive!).and_raise(SocketError) + allow_next_instance_of(described_class) do |instance| + allow(instance).to receive(:extract_zip_archive!).and_raise(SocketError) + end end it 'raises an error' do @@ -125,9 +126,10 @@ describe Projects::UpdatePagesService do context 'when failed to extract zip artifacts' do before do - expect_any_instance_of(described_class) - .to receive(:extract_zip_archive!) - .and_raise(Projects::UpdatePagesService::FailedToExtractError) + expect_next_instance_of(described_class) do |instance| + expect(instance).to receive(:extract_zip_archive!) + .and_raise(Projects::UpdatePagesService::FailedToExtractError) + end end it 'raises an error' do @@ -185,60 +187,20 @@ describe Projects::UpdatePagesService do .and_return(metadata) end - shared_examples 'pages size limit exceeded' do - it 'limits the maximum size of gitlab pages' do - subject.execute - - expect(deploy_status.description) - .to match(/artifacts for pages are too large/) - expect(deploy_status).to be_script_failure - expect(project.pages_metadatum).not_to be_deployed - end - end - context 'when maximum pages size is set to zero' do before do stub_application_setting(max_pages_size: 0) end - context 'when page size does not exceed internal maximum' do - before do - allow(metadata).to receive(:total_size).and_return(200.megabytes) - end - - it 'updates pages correctly' do - subject.execute - - expect(deploy_status.description).not_to be_present - expect(project.pages_metadatum).to be_deployed - end - end - - context 'when pages size does exceed internal maximum' do - before do - allow(metadata).to receive(:total_size).and_return(2.terabytes) - end - - it_behaves_like 'pages size limit exceeded' - end - end - - context 'when pages size is greater than max size setting' do - before do - stub_application_setting(max_pages_size: 200) - allow(metadata).to receive(:total_size).and_return(201.megabytes) - end - - it_behaves_like 'pages size limit exceeded' + it_behaves_like 'pages size limit is', ::Gitlab::Pages::MAX_SIZE end - context 'when max size setting is greater than internal max size' do + context 'when size is limited on the instance level' do before do - stub_application_setting(max_pages_size: 3.terabytes / 1.megabyte) - allow(metadata).to receive(:total_size).and_return(2.terabytes) + stub_application_setting(max_pages_size: 100) end - it_behaves_like 'pages size limit exceeded' + it_behaves_like 'pages size limit is', 100.megabytes end end diff --git a/spec/services/prometheus/proxy_service_spec.rb b/spec/services/prometheus/proxy_service_spec.rb index 03bda94e9c6c1a3eca6cce1fa55b80dd2fb23597..5a036194d01aa4c2555a98c40216b70110cdef5f 100644 --- a/spec/services/prometheus/proxy_service_spec.rb +++ b/spec/services/prometheus/proxy_service_spec.rb @@ -5,8 +5,14 @@ require 'spec_helper' describe Prometheus::ProxyService do include ReactiveCachingHelpers - set(:project) { create(:project) } - set(:environment) { create(:environment, project: project) } + let_it_be(:project) { create(:project) } + let_it_be(:environment) { create(:environment, project: project) } + + describe 'configuration' do + it 'ReactiveCaching refresh is not needed' do + expect(described_class.reactive_cache_refresh_interval).to be > described_class.reactive_cache_lifetime + end + end describe '#initialize' do let(:params) { ActionController::Parameters.new(query: '1').permit! } diff --git a/spec/services/prometheus/proxy_variable_substitution_service_spec.rb b/spec/services/prometheus/proxy_variable_substitution_service_spec.rb index b1cdb8fd3ae0849b76130e710f3cfe7ba3630f12..9978c631366056572fe65ec987fb631d7b29964e 100644 --- a/spec/services/prometheus/proxy_variable_substitution_service_spec.rb +++ b/spec/services/prometheus/proxy_variable_substitution_service_spec.rb @@ -39,8 +39,12 @@ describe Prometheus::ProxyVariableSubstitutionService do end context 'with predefined variables' do + let(:params_keys) { { query: 'up{%{environment_filter}}' } } + it_behaves_like 'success' do - let(:expected_query) { %Q[up{environment="#{environment.slug}"}] } + let(:expected_query) do + %Q[up{container_name!="POD",environment="#{environment.slug}"}] + end end context 'with nil query' do @@ -50,6 +54,133 @@ describe Prometheus::ProxyVariableSubstitutionService do let(:expected_query) { nil } end end + + context 'with liquid format' do + let(:params_keys) do + { query: 'up{environment="{{ci_environment_slug}}"}' } + end + + it_behaves_like 'success' do + let(:expected_query) { %Q[up{environment="#{environment.slug}"}] } + end + end + + context 'with ruby and liquid formats' do + let(:params_keys) do + { query: 'up{%{environment_filter},env2="{{ci_environment_slug}}"}' } + end + + it_behaves_like 'success' do + let(:expected_query) do + %Q[up{container_name!="POD",environment="#{environment.slug}",env2="#{environment.slug}"}] + end + end + end + end + + context 'with custom variables' do + let(:pod_name) { "pod1" } + + let(:params_keys) do + { + query: 'up{pod_name="{{pod_name}}"}', + variables: ['pod_name', pod_name] + } + end + + it_behaves_like 'success' do + let(:expected_query) { %q[up{pod_name="pod1"}] } + end + + context 'with ruby variable interpolation format' do + let(:params_keys) do + { + query: 'up{pod_name="%{pod_name}"}', + variables: ['pod_name', pod_name] + } + end + + it_behaves_like 'success' do + # Custom variables cannot be used with the Ruby interpolation format. + let(:expected_query) { "up{pod_name=\"%{pod_name}\"}" } + end + end + + context 'with predefined variables in variables parameter' do + let(:params_keys) do + { + query: 'up{pod_name="{{pod_name}}",env="{{ci_environment_slug}}"}', + variables: ['pod_name', pod_name, 'ci_environment_slug', 'custom_value'] + } + end + + it_behaves_like 'success' do + # Predefined variable values should not be overwritten by custom variable + # values. + let(:expected_query) { "up{pod_name=\"#{pod_name}\",env=\"#{environment.slug}\"}" } + end + end + + context 'with invalid variables parameter' do + let(:params_keys) do + { + query: 'up{pod_name="{{pod_name}}"}', + variables: ['a'] + } + end + + it_behaves_like 'error', 'Optional parameter "variables" must be an ' \ + 'array of keys and values. Ex: [key1, value1, key2, value2]' + end + + context 'with nil variables' do + let(:params_keys) do + { + query: 'up{pod_name="{{pod_name}}"}', + variables: nil + } + end + + it_behaves_like 'success' do + let(:expected_query) { 'up{pod_name=""}' } + end + end + + context 'with ruby and liquid variables' do + let(:params_keys) do + { + query: 'up{env1="%{ruby_variable}",env2="{{ liquid_variable }}"}', + variables: %w(ruby_variable value liquid_variable env_slug) + } + end + + it_behaves_like 'success' do + # It should replace only liquid variables with their values + let(:expected_query) { %q[up{env1="%{ruby_variable}",env2="env_slug"}] } + end + end + end + + context 'with liquid tags and ruby format variables' do + let(:params_keys) do + { + query: 'up{ {% if true %}env1="%{ci_environment_slug}",' \ + 'env2="{{ci_environment_slug}}"{% endif %} }' + } + end + + # The following spec will fail and should be changed to a 'success' spec + # once we remove support for the Ruby interpolation format. + # https://gitlab.com/gitlab-org/gitlab/issues/37990 + # + # Liquid tags `{% %}` cannot be used currently because the Ruby `%` + # operator raises an error when it encounters a Liquid `{% %}` tag in the + # string. + # + # Once we remove support for the Ruby format, users can start using + # Liquid tags. + + it_behaves_like 'error', 'Malformed string' end context 'ruby template rendering' do @@ -139,5 +270,18 @@ describe Prometheus::ProxyVariableSubstitutionService do end end end + + context 'when liquid template rendering raises error' do + before do + liquid_service = instance_double(TemplateEngines::LiquidService) + + allow(TemplateEngines::LiquidService).to receive(:new).and_return(liquid_service) + allow(liquid_service).to receive(:render).and_raise( + TemplateEngines::LiquidService::RenderError, 'error message' + ) + end + + it_behaves_like 'error', 'error message' + end end end diff --git a/spec/services/quick_actions/interpret_service_spec.rb b/spec/services/quick_actions/interpret_service_spec.rb index b105e1e40ced0cdb666dcf96410c279cd88003ed..b2576cae5755f27c53001f299899ad952d605c37 100644 --- a/spec/services/quick_actions/interpret_service_spec.rb +++ b/spec/services/quick_actions/interpret_service_spec.rb @@ -856,9 +856,10 @@ describe QuickActions::InterpretService do end context 'only group milestones available' do - let(:group) { create(:group) } + let(:ancestor_group) { create(:group) } + let(:group) { create(:group, parent: ancestor_group) } let(:project) { create(:project, :public, namespace: group) } - let(:milestone) { create(:milestone, group: group, title: '10.0') } + let(:milestone) { create(:milestone, group: ancestor_group, title: '10.0') } it_behaves_like 'milestone command' do let(:content) { "/milestone %#{milestone.title}" } diff --git a/spec/services/releases/update_service_spec.rb b/spec/services/releases/update_service_spec.rb index 178bac3574f3ff7bf3e0726507dddec491abc1c2..f6c708735406bb6a091e33bea69369d8bb675f48 100644 --- a/spec/services/releases/update_service_spec.rb +++ b/spec/services/releases/update_service_spec.rb @@ -21,6 +21,7 @@ describe Releases::UpdateService do it 'raises an error' do result = service.execute expect(result[:status]).to eq(:error) + expect(result[:milestones_updated]).to be_falsy end end @@ -50,21 +51,33 @@ describe Releases::UpdateService do end context 'when a milestone is passed in' do - let(:new_title) { 'v2.0' } let(:milestone) { create(:milestone, project: project, title: 'v1.0') } - let(:new_milestone) { create(:milestone, project: project, title: new_title) } let(:params_with_milestone) { params.merge!({ milestones: [new_title] }) } + let(:new_milestone) { create(:milestone, project: project, title: new_title) } let(:service) { described_class.new(new_milestone.project, user, params_with_milestone) } before do release.milestones << milestone + end - service.execute - release.reload + context 'a different milestone' do + let(:new_title) { 'v2.0' } + + it 'updates the related milestone accordingly' do + result = service.execute + release.reload + + expect(release.milestones.first.title).to eq(new_title) + expect(result[:milestones_updated]).to be_truthy + end end - it 'updates the related milestone accordingly' do - expect(release.milestones.first.title).to eq(new_title) + context 'an identical milestone' do + let(:new_title) { 'v1.0' } + + it "raises an error" do + expect { service.execute }.to raise_error(ActiveRecord::RecordInvalid) + end end end @@ -76,12 +89,14 @@ describe Releases::UpdateService do release.milestones << milestone service.params = params_with_empty_milestone - service.execute - release.reload end it 'removes the old milestone and does not associate any new milestone' do + result = service.execute + release.reload + expect(release.milestones).not_to be_present + expect(result[:milestones_updated]).to be_truthy end end @@ -96,14 +111,15 @@ describe Releases::UpdateService do create(:milestone, project: project, title: new_title_1) create(:milestone, project: project, title: new_title_2) release.milestones << milestone - - service.execute - release.reload end it 'removes the old milestone and update the release with the new ones' do + result = service.execute + release.reload + milestone_titles = release.milestones.map(&:title) expect(milestone_titles).to match_array([new_title_1, new_title_2]) + expect(result[:milestones_updated]).to be_truthy end end end diff --git a/spec/services/resource_events/change_labels_service_spec.rb b/spec/services/resource_events/change_labels_service_spec.rb index 070964eb1ec886f3ab12f77b952a97dd8ef729e0..2b987b7fec9abea31ebd234dfd31fb2a7780d9b5 100644 --- a/spec/services/resource_events/change_labels_service_spec.rb +++ b/spec/services/resource_events/change_labels_service_spec.rb @@ -3,8 +3,8 @@ require 'spec_helper' describe ResourceEvents::ChangeLabelsService do - set(:project) { create(:project) } - set(:author) { create(:user) } + let_it_be(:project) { create(:project) } + let_it_be(:author) { create(:user) } let(:resource) { create(:issue, project: project) } describe '.change_labels' do diff --git a/spec/services/resource_events/merge_into_notes_service_spec.rb b/spec/services/resource_events/merge_into_notes_service_spec.rb index 72467091791c01521cfb1d9cb13c6cdb7fc35e9f..6bad1b86fca9ce9eaa04fe24291cd560ae69ed34 100644 --- a/spec/services/resource_events/merge_into_notes_service_spec.rb +++ b/spec/services/resource_events/merge_into_notes_service_spec.rb @@ -16,11 +16,11 @@ describe ResourceEvents::MergeIntoNotesService do create(:note_on_issue, opts.merge(params)) end - set(:project) { create(:project) } - set(:user) { create(:user) } - set(:resource) { create(:issue, project: project) } - set(:label) { create(:label, project: project) } - set(:label2) { create(:label, project: project) } + let_it_be(:project) { create(:project) } + let_it_be(:user) { create(:user) } + let_it_be(:resource) { create(:issue, project: project) } + let_it_be(:label) { create(:label, project: project) } + let_it_be(:label2) { create(:label, project: project) } let(:time) { Time.now } describe '#execute' do diff --git a/spec/services/resource_events/synthetic_label_notes_builder_service_spec.rb b/spec/services/resource_events/synthetic_label_notes_builder_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..41902bc1da13c4a9bfa2fa19e4b6132f4cd6959d --- /dev/null +++ b/spec/services/resource_events/synthetic_label_notes_builder_service_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe ResourceEvents::SyntheticLabelNotesBuilderService do + describe '#execute' do + let!(:user) { create(:user) } + + let!(:issue) { create(:issue, author: user) } + + let!(:event1) { create(:resource_label_event, issue: issue) } + let!(:event2) { create(:resource_label_event, issue: issue) } + let!(:event3) { create(:resource_label_event, issue: issue) } + + it 'returns the expected synthetic notes' do + notes = ResourceEvents::SyntheticLabelNotesBuilderService.new(issue, user).execute + + expect(notes.size).to eq(3) + end + end +end diff --git a/spec/services/snippets/create_service_spec.rb b/spec/services/snippets/create_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..6f7ce7959ff35321ef8461f1b7e2e78016fe8bd4 --- /dev/null +++ b/spec/services/snippets/create_service_spec.rb @@ -0,0 +1,170 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Snippets::CreateService do + describe '#execute' do + let_it_be(:user) { create(:user) } + let_it_be(:admin) { create(:user, :admin) } + let(:opts) { base_opts.merge(extra_opts) } + let(:base_opts) do + { + title: 'Test snippet', + file_name: 'snippet.rb', + content: 'puts "hello world"', + visibility_level: Gitlab::VisibilityLevel::PRIVATE + } + end + let(:extra_opts) { {} } + let(:creator) { admin } + + subject { Snippets::CreateService.new(project, creator, opts).execute } + + let(:snippet) { subject.payload[:snippet] } + + shared_examples 'a service that creates a snippet' do + it 'creates a snippet with the provided attributes' do + expect(snippet.title).to eq(opts[:title]) + expect(snippet.file_name).to eq(opts[:file_name]) + expect(snippet.content).to eq(opts[:content]) + expect(snippet.visibility_level).to eq(opts[:visibility_level]) + end + end + + shared_examples 'public visibility level restrictions apply' do + let(:extra_opts) { { visibility_level: Gitlab::VisibilityLevel::PUBLIC } } + + before do + stub_application_setting(restricted_visibility_levels: [Gitlab::VisibilityLevel::PUBLIC]) + end + + context 'when user is not an admin' do + let(:creator) { user } + + it 'responds with an error' do + expect(subject).to be_error + end + + it 'does not create a public snippet' do + expect(subject.message).to match('has been restricted') + end + end + + context 'when user is an admin' do + it 'responds with success' do + expect(subject).to be_success + end + + it 'creates a public snippet' do + expect(snippet.visibility_level).to eq(Gitlab::VisibilityLevel::PUBLIC) + end + end + + describe 'when visibility level is passed as a string' do + let(:extra_opts) { { visibility: 'internal' } } + + before do + base_opts.delete(:visibility_level) + end + + it 'assigns the correct visibility level' do + expect(subject).to be_success + expect(snippet.visibility_level).to eq(Gitlab::VisibilityLevel::INTERNAL) + end + end + end + + shared_examples 'spam check is performed' do + shared_examples 'marked as spam' do + it 'marks a snippet as spam ' do + expect(snippet).to be_spam + end + + it 'invalidates the snippet' do + expect(snippet).to be_invalid + end + + it 'creates a new spam_log' do + expect { snippet } + .to log_spam(title: snippet.title, noteable_type: snippet.class.name) + end + + it 'assigns a spam_log to an issue' do + expect(snippet.spam_log).to eq(SpamLog.last) + end + end + + let(:extra_opts) do + { visibility_level: Gitlab::VisibilityLevel::PUBLIC, request: double(:request, env: {}) } + end + + before do + expect_next_instance_of(AkismetService) do |akismet_service| + expect(akismet_service).to receive_messages(spam?: true) + end + end + + [true, false, nil].each do |allow_possible_spam| + context "when recaptcha_disabled flag is #{allow_possible_spam.inspect}" do + before do + stub_feature_flags(allow_possible_spam: allow_possible_spam) unless allow_possible_spam.nil? + end + + it_behaves_like 'marked as spam' + end + end + end + + shared_examples 'snippet create data is tracked' do + let(:counter) { Gitlab::UsageDataCounters::SnippetCounter } + + it 'increments count when create succeeds' do + expect { subject }.to change { counter.read(:create) }.by 1 + end + + context 'when create fails' do + let(:opts) { {} } + + it 'does not increment count' do + expect { subject }.not_to change { counter.read(:create) } + end + end + end + + shared_examples 'an error service response when save fails' do + let(:extra_opts) { { content: nil } } + + it 'responds with an error' do + expect(subject).to be_error + end + + it 'does not create the snippet' do + expect { subject }.not_to change { Snippet.count } + end + end + + context 'when Project Snippet' do + let_it_be(:project) { create(:project) } + + before do + project.add_developer(user) + end + + it_behaves_like 'a service that creates a snippet' + it_behaves_like 'public visibility level restrictions apply' + it_behaves_like 'spam check is performed' + it_behaves_like 'snippet create data is tracked' + it_behaves_like 'an error service response when save fails' + end + + context 'when PersonalSnippet' do + let(:project) { nil } + + it_behaves_like 'a service that creates a snippet' + it_behaves_like 'public visibility level restrictions apply' + it_behaves_like 'spam check is performed' + it_behaves_like 'snippet create data is tracked' + it_behaves_like 'an error service response when save fails' + end + end +end diff --git a/spec/services/snippets/destroy_service_spec.rb b/spec/services/snippets/destroy_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..bb035d275ab0ac6fdeace958b996a904fad4846d --- /dev/null +++ b/spec/services/snippets/destroy_service_spec.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Snippets::DestroyService do + let_it_be(:project) { create(:project) } + let_it_be(:user) { create(:user) } + let_it_be(:other_user) { create(:user) } + + describe '#execute' do + subject { Snippets::DestroyService.new(user, snippet).execute } + + context 'when snippet is nil' do + let(:snippet) { nil } + + it 'returns a ServiceResponse error' do + expect(subject).to be_error + end + end + + shared_examples 'a successful destroy' do + it 'deletes the snippet' do + expect { subject }.to change { Snippet.count }.by(-1) + end + + it 'returns ServiceResponse success' do + expect(subject).to be_success + end + end + + shared_examples 'an unsuccessful destroy' do + it 'does not delete the snippet' do + expect { subject }.to change { Snippet.count }.by(0) + end + + it 'returns ServiceResponse error' do + expect(subject).to be_error + end + end + + context 'when ProjectSnippet' do + let!(:snippet) { create(:project_snippet, project: project, author: author) } + + context 'when user is able to admin_project_snippet' do + let(:author) { user } + + before do + project.add_developer(user) + end + + it_behaves_like 'a successful destroy' + end + + context 'when user is not able to admin_project_snippet' do + let(:author) { other_user } + + it_behaves_like 'an unsuccessful destroy' + end + end + + context 'when PersonalSnippet' do + let!(:snippet) { create(:personal_snippet, author: author) } + + context 'when user is able to admin_personal_snippet' do + let(:author) { user } + + it_behaves_like 'a successful destroy' + end + + context 'when user is not able to admin_personal_snippet' do + let(:author) { other_user } + + it_behaves_like 'an unsuccessful destroy' + end + end + end +end diff --git a/spec/services/snippets/update_service_spec.rb b/spec/services/snippets/update_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..b8215f9779d5acb50ab0e5fb1df5ee6a43baf29e --- /dev/null +++ b/spec/services/snippets/update_service_spec.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Snippets::UpdateService do + describe '#execute' do + let_it_be(:user) { create(:user) } + let_it_be(:admin) { create :user, admin: true } + let(:visibility_level) { Gitlab::VisibilityLevel::PRIVATE } + let(:options) do + { + title: 'Test snippet', + file_name: 'snippet.rb', + content: 'puts "hello world"', + visibility_level: visibility_level + } + end + let(:updater) { user } + + subject do + Snippets::UpdateService.new( + project, + updater, + options + ).execute(snippet) + end + + shared_examples 'a service that updates a snippet' do + it 'updates a snippet with the provided attributes' do + expect { subject }.to change { snippet.title }.from(snippet.title).to(options[:title]) + .and change { snippet.file_name }.from(snippet.file_name).to(options[:file_name]) + .and change { snippet.content }.from(snippet.content).to(options[:content]) + end + end + + shared_examples 'public visibility level restrictions apply' do + let(:visibility_level) { Gitlab::VisibilityLevel::PUBLIC } + + before do + stub_application_setting(restricted_visibility_levels: [Gitlab::VisibilityLevel::PUBLIC]) + end + + context 'when user is not an admin' do + it 'responds with an error' do + expect(subject).to be_error + end + + it 'does not update snippet to public visibility' do + original_visibility = snippet.visibility_level + + expect(subject.message).to match('has been restricted') + expect(snippet.visibility_level).to eq(original_visibility) + end + end + + context 'when user is an admin' do + let(:updater) { admin } + + it 'responds with success' do + expect(subject).to be_success + end + + it 'updates the snippet to public visibility' do + old_visibility = snippet.visibility_level + + expect(subject.payload[:snippet]).not_to be_nil + expect(snippet.visibility_level).not_to eq(old_visibility) + expect(snippet.visibility_level).to eq(Gitlab::VisibilityLevel::PUBLIC) + end + end + + context 'when visibility level is passed as a string' do + before do + options[:visibility] = 'internal' + options.delete(:visibility_level) + end + + it 'assigns the correct visibility level' do + expect(subject).to be_success + expect(snippet.visibility_level).to eq(Gitlab::VisibilityLevel::INTERNAL) + end + end + end + + shared_examples 'snippet update data is tracked' do + let(:counter) { Gitlab::UsageDataCounters::SnippetCounter } + + it 'increments count when create succeeds' do + expect { subject }.to change { counter.read(:update) }.by 1 + end + + context 'when update fails' do + let(:options) { { title: '' } } + + it 'does not increment count' do + expect { subject }.not_to change { counter.read(:update) } + end + end + end + + context 'when Project Snippet' do + let_it_be(:project) { create(:project) } + let!(:snippet) { create(:project_snippet, author: user, project: project) } + + before do + project.add_developer(user) + end + + it_behaves_like 'a service that updates a snippet' + it_behaves_like 'public visibility level restrictions apply' + it_behaves_like 'snippet update data is tracked' + end + + context 'when PersonalSnippet' do + let(:project) { nil } + let!(:snippet) { create(:personal_snippet, author: user) } + + it_behaves_like 'a service that updates a snippet' + it_behaves_like 'public visibility level restrictions apply' + it_behaves_like 'snippet update data is tracked' + end + end +end diff --git a/spec/services/spam/mark_as_spam_service_spec.rb b/spec/services/spam/mark_as_spam_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..cba9d6a39cb39fc2f37d38bdc488a3917566818b --- /dev/null +++ b/spec/services/spam/mark_as_spam_service_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Spam::MarkAsSpamService do + let(:user_agent_detail) { build(:user_agent_detail) } + let(:spammable) { build(:issue, user_agent_detail: user_agent_detail) } + let(:fake_akismet_service) { double(:akismet_service, submit_spam: true) } + + subject { described_class.new(spammable: spammable) } + + describe '#execute' do + before do + allow(subject).to receive(:akismet).and_return(fake_akismet_service) + end + + context 'when the spammable object is not submittable' do + before do + allow(spammable).to receive(:submittable_as_spam?).and_return false + end + + it 'does not submit as spam' do + expect(subject.execute).to be_falsey + end + end + + context 'spam is submitted successfully' do + before do + allow(spammable).to receive(:submittable_as_spam?).and_return true + allow(fake_akismet_service).to receive(:submit_spam).and_return true + end + + it 'submits as spam' do + expect(subject.execute).to be_truthy + end + + it "updates the spammable object's user agent detail as being submitted as spam" do + expect(user_agent_detail).to receive(:update_attribute) + + subject.execute + end + + context 'when Akismet does not consider it spam' do + it 'does not update the spammable object as spam' do + allow(fake_akismet_service).to receive(:submit_spam).and_return false + + expect(subject.execute).to be_falsey + end + end + end + end +end diff --git a/spec/services/spam_service_spec.rb b/spec/services/spam_service_spec.rb index 76f775836125dce68fc0272b376a8a57857de185..c8ebe87e4d20576849f16f43f10b686027a0f582 100644 --- a/spec/services/spam_service_spec.rb +++ b/spec/services/spam_service_spec.rb @@ -5,7 +5,7 @@ require 'spec_helper' describe SpamService do describe '#when_recaptcha_verified' do def check_spam(issue, request, recaptcha_verified) - described_class.new(issue, request).when_recaptcha_verified(recaptcha_verified) do + described_class.new(spammable: issue, request: request).when_recaptcha_verified(recaptcha_verified) do 'yielded' end end @@ -45,7 +45,7 @@ describe SpamService do context 'when indicated as spam by akismet' do shared_examples 'akismet spam' do - it 'doesnt check as spam when request is missing' do + it "doesn't check as spam when request is missing" do check_spam(issue, nil, false) expect(issue).not_to be_spam diff --git a/spec/services/suggestions/apply_service_spec.rb b/spec/services/suggestions/apply_service_spec.rb index bdbcb0fdb0748452d378d682da48505fb0c55879..84529af71877676be5400dcf78ea2884d4ece4b4 100644 --- a/spec/services/suggestions/apply_service_spec.rb +++ b/spec/services/suggestions/apply_service_spec.rb @@ -48,10 +48,34 @@ describe Suggestions::ApplyService do expect(commit.committer_email).to eq(user.commit_email) expect(commit.author_name).to eq(user.name) end + + context 'when a custom suggestion commit message' do + before do + project.update!(suggestion_commit_message: message) + + apply(suggestion) + end + + context 'is not specified' do + let(:message) { nil } + + it 'sets default commit message' do + expect(project.repository.commit.message).to eq("Apply suggestion to files/ruby/popen.rb") + end + end + + context 'is specified' do + let(:message) { 'refactor: %{project_path} %{project_name} %{file_path} %{branch_name} %{username} %{user_full_name}' } + + it 'sets custom commit message' do + expect(project.repository.commit.message).to eq("refactor: project-1 Project_1 files/ruby/popen.rb master test.user Test User") + end + end + end end - let(:project) { create(:project, :repository) } - let(:user) { create(:user, :commit_email) } + let(:project) { create(:project, :repository, path: 'project-1', name: 'Project_1') } + let(:user) { create(:user, :commit_email, name: 'Test User', username: 'test.user') } let(:position) { build_position } @@ -113,7 +137,8 @@ describe Suggestions::ApplyService do context 'non-fork project' do let(:merge_request) do create(:merge_request, source_project: project, - target_project: project) + target_project: project, + source_branch: 'master') end before do diff --git a/spec/services/system_note_service_spec.rb b/spec/services/system_note_service_spec.rb index a952e26e33810a1a4aaecc75790256951a5c16b8..4ba22af85f0c1bef4bc4dbc8b1f7226dc6113807 100644 --- a/spec/services/system_note_service_spec.rb +++ b/spec/services/system_note_service_spec.rb @@ -7,9 +7,9 @@ describe SystemNoteService do include RepoHelpers include AssetsHelpers - set(:group) { create(:group) } - set(:project) { create(:project, :repository, group: group) } - set(:author) { create(:user) } + let_it_be(:group) { create(:group) } + let_it_be(:project) { create(:project, :repository, group: group) } + let_it_be(:author) { create(:user) } let(:noteable) { create(:issue, project: project) } let(:issue) { noteable } @@ -76,28 +76,14 @@ describe SystemNoteService do end describe '.change_due_date' do - subject { described_class.change_due_date(noteable, project, author, due_date) } + let(:due_date) { double } - let(:due_date) { Date.today } - - it_behaves_like 'a note with overridable created_at' - - it_behaves_like 'a system note' do - let(:action) { 'due_date' } - end - - context 'when due date added' do - it 'sets the note text' do - expect(subject.note).to eq "changed due date to #{Date.today.to_s(:long)}" + it 'calls TimeTrackingService' do + expect_next_instance_of(::SystemNotes::TimeTrackingService) do |service| + expect(service).to receive(:change_due_date).with(due_date) end - end - context 'when due date removed' do - let(:due_date) { nil } - - it 'sets the note text' do - expect(subject.note).to eq 'removed due date' - end + described_class.change_due_date(noteable, project, author, due_date) end end @@ -488,36 +474,12 @@ describe SystemNoteService do end describe '.change_time_estimate' do - subject { described_class.change_time_estimate(noteable, project, author) } - - it_behaves_like 'a system note' do - let(:action) { 'time_tracking' } - end - - context 'with a time estimate' do - it 'sets the note text' do - noteable.update_attribute(:time_estimate, 277200) - - expect(subject.note).to eq "changed time estimate to 1w 4d 5h" - end - - context 'when time_tracking_limit_to_hours setting is true' do - before do - stub_application_setting(time_tracking_limit_to_hours: true) - end - - it 'sets the note text' do - noteable.update_attribute(:time_estimate, 277200) - - expect(subject.note).to eq "changed time estimate to 77h" - end + it 'calls TimeTrackingService' do + expect_next_instance_of(::SystemNotes::TimeTrackingService) do |service| + expect(service).to receive(:change_time_estimate) end - end - context 'without a time estimate' do - it 'sets the note text' do - expect(subject.note).to eq "removed time estimate" - end + described_class.change_time_estimate(noteable, project, author) end end @@ -548,61 +510,12 @@ describe SystemNoteService do end describe '.change_time_spent' do - # We need a custom noteable in order to the shared examples to be green. - let(:noteable) do - mr = create(:merge_request, source_project: project) - mr.spend_time(duration: 360000, user_id: author.id) - mr.save! - mr - end - - subject do - described_class.change_time_spent(noteable, project, author) - end - - it_behaves_like 'a system note' do - let(:action) { 'time_tracking' } - end - - context 'when time was added' do - it 'sets the note text' do - spend_time!(277200) - - expect(subject.note).to eq "added 1w 4d 5h of time spent" + it 'calls TimeTrackingService' do + expect_next_instance_of(::SystemNotes::TimeTrackingService) do |service| + expect(service).to receive(:change_time_spent) end - end - - context 'when time was subtracted' do - it 'sets the note text' do - spend_time!(-277200) - - expect(subject.note).to eq "subtracted 1w 4d 5h of time spent" - end - end - - context 'when time was removed' do - it 'sets the note text' do - spend_time!(:reset) - expect(subject.note).to eq "removed time spent" - end - end - - context 'when time_tracking_limit_to_hours setting is true' do - before do - stub_application_setting(time_tracking_limit_to_hours: true) - end - - it 'sets the note text' do - spend_time!(277200) - - expect(subject.note).to eq "added 77h of time spent" - end - end - - def spend_time!(seconds) - noteable.spend_time(duration: seconds, user_id: author.id) - noteable.save! + described_class.change_time_spent(noteable, project, author) end end diff --git a/spec/services/system_notes/commit_service_spec.rb b/spec/services/system_notes/commit_service_spec.rb index 4d4403be59a346ccc781e7ac1783dc3f52a0ae31..5839a17e4a0d4ab423f85f7bb333acac8244738b 100644 --- a/spec/services/system_notes/commit_service_spec.rb +++ b/spec/services/system_notes/commit_service_spec.rb @@ -3,9 +3,9 @@ require 'spec_helper' describe SystemNotes::CommitService do - set(:group) { create(:group) } - set(:project) { create(:project, :repository, group: group) } - set(:author) { create(:user) } + let_it_be(:group) { create(:group) } + let_it_be(:project) { create(:project, :repository, group: group) } + let_it_be(:author) { create(:user) } let(:commit_service) { described_class.new(noteable: noteable, project: project, author: author) } diff --git a/spec/services/system_notes/issuables_service_spec.rb b/spec/services/system_notes/issuables_service_spec.rb index c2f627c681b7a13cb7286c6680f3f5e5e27432a4..56ef0039b637609408db4cfc716dfb14d4863844 100644 --- a/spec/services/system_notes/issuables_service_spec.rb +++ b/spec/services/system_notes/issuables_service_spec.rb @@ -265,7 +265,9 @@ describe ::SystemNotes::IssuablesService do context 'when cross-reference disallowed' do before do - expect_any_instance_of(described_class).to receive(:cross_reference_disallowed?).and_return(true) + expect_next_instance_of(described_class) do |instance| + expect(instance).to receive(:cross_reference_disallowed?).and_return(true) + end end it 'returns nil' do @@ -279,7 +281,9 @@ describe ::SystemNotes::IssuablesService do context 'when cross-reference allowed' do before do - expect_any_instance_of(described_class).to receive(:cross_reference_disallowed?).and_return(false) + expect_next_instance_of(described_class) do |instance| + expect(instance).to receive(:cross_reference_disallowed?).and_return(false) + end end it_behaves_like 'a system note' do diff --git a/spec/services/system_notes/time_tracking_service_spec.rb b/spec/services/system_notes/time_tracking_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..7e3e6a75cdf494912f82ea840dd2683ce6c9a117 --- /dev/null +++ b/spec/services/system_notes/time_tracking_service_spec.rb @@ -0,0 +1,129 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe ::SystemNotes::TimeTrackingService do + let_it_be(:author) { create(:user) } + let_it_be(:project) { create(:project, :repository) } + + let(:noteable) { create(:issue, project: project) } + + describe '#change_due_date' do + subject { described_class.new(noteable: noteable, project: project, author: author).change_due_date(due_date) } + + let(:due_date) { Date.today } + + it_behaves_like 'a note with overridable created_at' + + it_behaves_like 'a system note' do + let(:action) { 'due_date' } + end + + context 'when due date added' do + it 'sets the note text' do + expect(subject.note).to eq "changed due date to #{due_date.to_s(:long)}" + end + end + + context 'when due date removed' do + let(:due_date) { nil } + + it 'sets the note text' do + expect(subject.note).to eq 'removed due date' + end + end + end + + describe '.change_time_estimate' do + subject { described_class.new(noteable: noteable, project: project, author: author).change_time_estimate } + + it_behaves_like 'a system note' do + let(:action) { 'time_tracking' } + end + + context 'with a time estimate' do + it 'sets the note text' do + noteable.update_attribute(:time_estimate, 277200) + + expect(subject.note).to eq "changed time estimate to 1w 4d 5h" + end + + context 'when time_tracking_limit_to_hours setting is true' do + before do + stub_application_setting(time_tracking_limit_to_hours: true) + end + + it 'sets the note text' do + noteable.update_attribute(:time_estimate, 277200) + + expect(subject.note).to eq "changed time estimate to 77h" + end + end + end + + context 'without a time estimate' do + it 'sets the note text' do + expect(subject.note).to eq "removed time estimate" + end + end + end + + describe '.change_time_spent' do + # We need a custom noteable in order to the shared examples to be green. + let(:noteable) do + mr = create(:merge_request, source_project: project) + mr.spend_time(duration: 360000, user_id: author.id) + mr.save! + mr + end + + subject do + described_class.new(noteable: noteable, project: project, author: author).change_time_spent + end + + it_behaves_like 'a system note' do + let(:action) { 'time_tracking' } + end + + context 'when time was added' do + it 'sets the note text' do + spend_time!(277200) + + expect(subject.note).to eq "added 1w 4d 5h of time spent" + end + end + + context 'when time was subtracted' do + it 'sets the note text' do + spend_time!(-277200) + + expect(subject.note).to eq "subtracted 1w 4d 5h of time spent" + end + end + + context 'when time was removed' do + it 'sets the note text' do + spend_time!(:reset) + + expect(subject.note).to eq "removed time spent" + end + end + + context 'when time_tracking_limit_to_hours setting is true' do + before do + stub_application_setting(time_tracking_limit_to_hours: true) + end + + it 'sets the note text' do + spend_time!(277200) + + expect(subject.note).to eq "added 77h of time spent" + end + end + + def spend_time!(seconds) + noteable.spend_time(duration: seconds, user_id: author.id) + noteable.save! + end + end +end diff --git a/spec/services/task_list_toggle_service_spec.rb b/spec/services/task_list_toggle_service_spec.rb index a309951bbcb771f61923b04a9fb33a05a9cc7cd5..82a5446dcb8fbf3bea7d47c14bbb8de953cc6817 100644 --- a/spec/services/task_list_toggle_service_spec.rb +++ b/spec/services/task_list_toggle_service_spec.rb @@ -121,7 +121,7 @@ describe TaskListToggleService do > * [x] Task 2 EOT - markdown_html = Banzai::Pipeline::FullPipeline.call(markdown, project: nil)[:output].to_html + markdown_html = parse_markdown(markdown) toggler = described_class.new(markdown, markdown_html, toggle_as_checked: true, line_source: '> > * [ ] Task 1', line_number: 1) @@ -142,7 +142,7 @@ describe TaskListToggleService do * [x] Task 2 EOT - markdown_html = Banzai::Pipeline::FullPipeline.call(markdown, project: nil)[:output].to_html + markdown_html = parse_markdown(markdown) toggler = described_class.new(markdown, markdown_html, toggle_as_checked: true, line_source: '* [ ] Task 1', line_number: 5) @@ -151,4 +151,44 @@ describe TaskListToggleService do expect(toggler.updated_markdown.lines[4]).to eq "* [x] Task 1\n" expect(toggler.updated_markdown_html).to include('disabled checked> Task 1') end + + context 'when clicking an embedded subtask' do + it 'properly handles it inside an unordered list' do + markdown = + <<-EOT.strip_heredoc + - - [ ] Task 1 + - [x] Task 2 + EOT + + markdown_html = parse_markdown(markdown) + toggler = described_class.new(markdown, markdown_html, + toggle_as_checked: true, + line_source: '- - [ ] Task 1', line_number: 1) + + expect(toggler.execute).to be_truthy + expect(toggler.updated_markdown.lines[0]).to eq "- - [x] Task 1\n" + expect(toggler.updated_markdown_html).to include('disabled checked> Task 1') + end + + it 'properly handles it inside an ordered list' do + markdown = + <<-EOT.strip_heredoc + 1. - [ ] Task 1 + - [x] Task 2 + EOT + + markdown_html = parse_markdown(markdown) + toggler = described_class.new(markdown, markdown_html, + toggle_as_checked: true, + line_source: '1. - [ ] Task 1', line_number: 1) + + expect(toggler.execute).to be_truthy + expect(toggler.updated_markdown.lines[0]).to eq "1. - [x] Task 1\n" + expect(toggler.updated_markdown_html).to include('disabled checked> Task 1') + end + end + + def parse_markdown(markdown) + Banzai::Pipeline::FullPipeline.call(markdown, project: nil)[:output].to_html + end end diff --git a/spec/services/template_engines/liquid_service_spec.rb b/spec/services/template_engines/liquid_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..7c5262bc264ecb466649cdf478cd4f3621ede333 --- /dev/null +++ b/spec/services/template_engines/liquid_service_spec.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe TemplateEngines::LiquidService do + describe '#render' do + let(:template) { 'up{env={{ci_environment_slug}}}' } + let(:result) { subject } + + let_it_be(:slug) { 'env_slug' } + + let_it_be(:context) do + { + ci_environment_slug: slug, + environment_filter: "container_name!=\"POD\",environment=\"#{slug}\"" + } + end + + subject { described_class.new(template).render(context) } + + it 'with symbol keys in context it substitutes variables' do + expect(result).to include("up{env=#{slug}") + end + + context 'with multiple occurrences of variable in template' do + let(:template) do + 'up{env1={{ci_environment_slug}},env2={{ci_environment_slug}}}' + end + + it 'substitutes variables' do + expect(result).to eq("up{env1=#{slug},env2=#{slug}}") + end + end + + context 'with multiple variables in template' do + let(:template) do + 'up{env={{ci_environment_slug}},' \ + '{{environment_filter}}}' + end + + it 'substitutes all variables' do + expect(result).to eq( + "up{env=#{slug}," \ + "container_name!=\"POD\",environment=\"#{slug}\"}" + ) + end + end + + context 'with unknown variables in template' do + let(:template) { 'up{env={{env_slug}}}' } + + it 'does not substitute unknown variables' do + expect(result).to eq("up{env=}") + end + end + + context 'with extra variables in context' do + let(:template) { 'up{env={{ci_environment_slug}}}' } + + it 'substitutes variables' do + # If context has only 1 key, there is no need for this spec. + expect(context.count).to be > 1 + expect(result).to eq("up{env=#{slug}}") + end + end + + context 'with unknown and known variables in template' do + let(:template) { 'up{env={{ci_environment_slug}},other_env={{env_slug}}}' } + + it 'substitutes known variables' do + expect(result).to eq("up{env=#{slug},other_env=}") + end + end + + context 'Liquid errors' do + shared_examples 'raises RenderError' do |message| + it do + expect { result }.to raise_error(described_class::RenderError, message) + end + end + + context 'when liquid raises error' do + let(:template) { 'up{env={{ci_environment_slug}}' } + let(:liquid_template) { Liquid::Template.new } + + before do + allow(Liquid::Template).to receive(:parse).with(template).and_return(liquid_template) + allow(liquid_template).to receive(:render!).and_raise(exception, message) + end + + context 'raises Liquid::MemoryError' do + let(:exception) { Liquid::MemoryError } + let(:message) { 'Liquid error: Memory limits exceeded' } + + it_behaves_like 'raises RenderError', 'Memory limit exceeded while rendering template' + end + + context 'raises Liquid::Error' do + let(:exception) { Liquid::Error } + let(:message) { 'Liquid error: Generic error message' } + + it_behaves_like 'raises RenderError', 'Error rendering query' + end + end + + context 'with template that is expensive to render' do + let(:template) do + '{% assign loop_count = 1000 %}'\ + '{% assign padStr = "0" %}'\ + '{% assign number_to_pad = "1" %}'\ + '{% assign strLength = number_to_pad | size %}'\ + '{% assign padLength = loop_count | minus: strLength %}'\ + '{% if padLength > 0 %}'\ + ' {% assign padded = number_to_pad %}'\ + ' {% for position in (1..padLength) %}'\ + ' {% assign padded = padded | prepend: padStr %}'\ + ' {% endfor %}'\ + ' {{ padded }}'\ + '{% endif %}' + end + + it_behaves_like 'raises RenderError', 'Memory limit exceeded while rendering template' + end + end + end +end diff --git a/spec/services/update_snippet_service_spec.rb b/spec/services/update_snippet_service_spec.rb deleted file mode 100644 index 19b35dca6a745650d029d701e0c779770e848a89..0000000000000000000000000000000000000000 --- a/spec/services/update_snippet_service_spec.rb +++ /dev/null @@ -1,80 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -describe UpdateSnippetService do - before do - @user = create :user - @admin = create :user, admin: true - @opts = { - title: 'Test snippet', - file_name: 'snippet.rb', - content: 'puts "hello world"', - visibility_level: Gitlab::VisibilityLevel::PRIVATE - } - end - - context 'When public visibility is restricted' do - before do - stub_application_setting(restricted_visibility_levels: [Gitlab::VisibilityLevel::PUBLIC]) - - @snippet = create_snippet(@project, @user, @opts) - @opts.merge!(visibility_level: Gitlab::VisibilityLevel::PUBLIC) - end - - it 'non-admins should not be able to update to public visibility' do - old_visibility = @snippet.visibility_level - update_snippet(@project, @user, @snippet, @opts) - expect(@snippet.errors.messages).to have_key(:visibility_level) - expect(@snippet.errors.messages[:visibility_level].first).to( - match('has been restricted') - ) - expect(@snippet.visibility_level).to eq(old_visibility) - end - - it 'admins should be able to update to public visibility' do - old_visibility = @snippet.visibility_level - update_snippet(@project, @admin, @snippet, @opts) - expect(@snippet.visibility_level).not_to eq(old_visibility) - expect(@snippet.visibility_level).to eq(Gitlab::VisibilityLevel::PUBLIC) - end - - describe "when visibility level is passed as a string" do - before do - @opts[:visibility] = 'internal' - @opts.delete(:visibility_level) - end - - it "assigns the correct visibility level" do - update_snippet(@project, @user, @snippet, @opts) - expect(@snippet.errors.any?).to be_falsey - expect(@snippet.visibility_level).to eq(Gitlab::VisibilityLevel::INTERNAL) - end - end - end - - describe 'usage counter' do - let(:counter) { Gitlab::UsageDataCounters::SnippetCounter } - let(:snippet) { create_snippet(nil, @user, @opts) } - - it 'increments count' do - expect do - update_snippet(nil, @admin, snippet, @opts) - end.to change { counter.read(:update) }.by 1 - end - - it 'does not increment count if create fails' do - expect do - update_snippet(nil, @admin, snippet, { title: '' }) - end.not_to change { counter.read(:update) } - end - end - - def create_snippet(project, user, opts) - CreateSnippetService.new(project, user, opts).execute - end - - def update_snippet(project, user, snippet, opts) - UpdateSnippetService.new(project, user, snippet, opts).execute - end -end diff --git a/spec/services/users/activity_service_spec.rb b/spec/services/users/activity_service_spec.rb index d8d2be87fd3a9718a816f06a510e6e22d9340569..f477eee1dd6f126e30023616075d596bf8730b7c 100644 --- a/spec/services/users/activity_service_spec.rb +++ b/spec/services/users/activity_service_spec.rb @@ -7,7 +7,7 @@ describe Users::ActivityService do let(:user) { create(:user, last_activity_on: last_activity_on) } - subject { described_class.new(user, 'type') } + subject { described_class.new(user) } describe '#execute', :clean_gitlab_redis_shared_state do context 'when last activity is nil' do @@ -40,7 +40,7 @@ describe Users::ActivityService do let(:fake_object) { double(username: 'hello') } it 'does not record activity' do - service = described_class.new(fake_object, 'pull') + service = described_class.new(fake_object) expect(service).not_to receive(:record_activity) diff --git a/spec/services/users/destroy_service_spec.rb b/spec/services/users/destroy_service_spec.rb index 23a0c71175ee049592c06f00c81cdc20373af59b..d9335cef5cc3f26a75038df5f4b4f7dcad630775 100644 --- a/spec/services/users/destroy_service_spec.rb +++ b/spec/services/users/destroy_service_spec.rb @@ -20,6 +20,22 @@ describe Users::DestroyService do expect { Namespace.find(namespace.id) }.to raise_error(ActiveRecord::RecordNotFound) end + it 'deletes user associations in batches' do + expect(user).to receive(:destroy_dependent_associations_in_batches) + + service.execute(user) + end + + context 'when :destroy_user_associations_in_batches flag is disabled' do + it 'does not delete user associations in batches' do + stub_feature_flags(destroy_user_associations_in_batches: false) + + expect(user).not_to receive(:destroy_dependent_associations_in_batches) + + service.execute(user) + end + end + it 'will delete the project' do expect_next_instance_of(Projects::DestroyService) do |destroy_service| expect(destroy_service).to receive(:execute).once.and_return(true) diff --git a/spec/services/users/update_service_spec.rb b/spec/services/users/update_service_spec.rb index 9384287f98a88b392b9276bc0e7cbf9a51321cfc..50bbb16e3688d921cc808194f04640bb8e069967 100644 --- a/spec/services/users/update_service_spec.rb +++ b/spec/services/users/update_service_spec.rb @@ -6,13 +6,6 @@ describe Users::UpdateService do let(:user) { create(:user) } describe '#execute' do - it 'updates the name' do - result = update_user(user, name: 'New Name') - - expect(result).to eq(status: :success) - expect(user.name).to eq('New Name') - end - it 'updates time preferences' do result = update_user(user, timezone: 'Europe/Warsaw', time_display_relative: true, time_format_in_24h: false) diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 1f0119108a8504128bba602dbc93c392b4483552..6393e4829045343a8810cfb033bd9880635cb2ee 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -245,6 +245,12 @@ RSpec.configure do |config| Rails.cache = caching_store end + config.around do |example| + # Wrap each example in it's own context to make sure the contexts don't + # leak + Labkit::Context.with_context { example.run } + end + config.around(:each, :clean_gitlab_redis_cache) do |example| redis_cache_cleanup! diff --git a/spec/support/cycle_analytics_helpers/test_generation.rb b/spec/support/cycle_analytics_helpers/test_generation.rb index 2096ec90c5bab9dc0e534a2d2abdf629f84bac65..340182633397fd63fa5533e742fbd5211c64f437 100644 --- a/spec/support/cycle_analytics_helpers/test_generation.rb +++ b/spec/support/cycle_analytics_helpers/test_generation.rb @@ -27,6 +27,8 @@ module CycleAnalyticsHelpers scenarios = combinations_of_start_time_conditions.product(combinations_of_end_time_conditions) scenarios.each do |start_time_conditions, end_time_conditions| + let_it_be(:other_project) { create(:project, :repository) } + context "start condition: #{start_time_conditions.map(&:first).to_sentence}" do context "end condition: #{end_time_conditions.map(&:first).to_sentence}" do it "finds the median of available durations between the two conditions", :sidekiq_might_not_need_inline do @@ -56,8 +58,6 @@ module CycleAnalyticsHelpers end context "when the data belongs to another project" do - let(:other_project) { create(:project, :repository) } - it "returns nil" do # Use a stub to "trick" the data/condition functions # into using another project. This saves us from having to @@ -117,7 +117,7 @@ module CycleAnalyticsHelpers data = data_fn[self] end_time = rand(1..10).days.from_now - end_time_conditions.each_with_index do |(condition_name, condition_fn), index| + end_time_conditions.each_with_index do |(_condition_name, condition_fn), index| Timecop.freeze(end_time + index.days) { condition_fn[self, data] } end diff --git a/spec/support/features/discussion_comments_shared_example.rb b/spec/support/features/discussion_comments_shared_example.rb index f070243f111865f921804dff75bcde6f687e54d5..ea13e91860a8dfbed5105277c6ede8c28b8152d0 100644 --- a/spec/support/features/discussion_comments_shared_example.rb +++ b/spec/support/features/discussion_comments_shared_example.rb @@ -297,11 +297,11 @@ shared_examples 'thread comments' do |resource_name| find("#{form_selector} .note-textarea").send_keys('a') end - it "should show a 'Comment & reopen #{resource_name}' button" do + it "shows a 'Comment & reopen #{resource_name}' button" do expect(find("#{form_selector} .js-note-target-reopen")).to have_content "Comment & reopen #{resource_name}" end - it "should show a 'Start thread & reopen #{resource_name}' button when 'Start thread' is selected" do + it "shows a 'Start thread & reopen #{resource_name}' button when 'Start thread' is selected" do find(toggle_selector).click find("#{menu_selector} li", match: :first) diff --git a/spec/support/helpers/filter_spec_helper.rb b/spec/support/helpers/filter_spec_helper.rb index 95c24d76dcd6a888560757652bee8ee34aa0fb88..45d49696e06519c2134c76eb7251ff34f2db049c 100644 --- a/spec/support/helpers/filter_spec_helper.rb +++ b/spec/support/helpers/filter_spec_helper.rb @@ -28,6 +28,17 @@ module FilterSpecHelper described_class.call(html, context) end + # Get an instance of the Filter class + # + # Use this for testing instance methods, but remember to test the result of + # the full pipeline by calling #call using the other methods in this helper. + def filter_instance + render_context = Banzai::RenderContext.new(project, current_user) + context = { project: project, current_user: current_user, render_context: render_context } + + described_class.new(input_text, context) + end + # Run text through HTML::Pipeline with the current filter and return the # result Hash # diff --git a/spec/support/helpers/filtered_search_helpers.rb b/spec/support/helpers/filtered_search_helpers.rb index 5dc87c369316066499b83fc62b33eb3f144d2552..c8b7a9251a9b506108eb289b12bf8e11e64dd854 100644 --- a/spec/support/helpers/filtered_search_helpers.rb +++ b/spec/support/helpers/filtered_search_helpers.rb @@ -26,7 +26,7 @@ module FilteredSearchHelpers # Select a label clicking in the search dropdown instead # of entering label names on the input. def select_label_on_dropdown(label_title) - input_filtered_search("label:", submit: false) + input_filtered_search("label=", submit: false) within('#js-dropdown-label') do wait_for_requests @@ -37,6 +37,10 @@ module FilteredSearchHelpers filtered_search.send_keys(:enter) end + def expect_filtered_search_dropdown_results(filter_dropdown, count) + expect(filter_dropdown).to have_selector('.filter-dropdown .filter-dropdown-item', count: count) + end + def expect_issues_list_count(open_count, closed_count = 0) all_count = open_count + closed_count @@ -67,7 +71,7 @@ module FilteredSearchHelpers end def init_label_search - filtered_search.set('label:') + filtered_search.set('label=') # This ensures the dropdown is shown expect(find('#js-dropdown-label')).not_to have_css('.filter-dropdown-loading') end @@ -86,6 +90,7 @@ module FilteredSearchHelpers el = token_elements[index] expect(el.find('.name')).to have_content(token[:name]) + expect(el.find('.operator')).to have_content(token[:operator]) if token[:operator].present? expect(el.find('.value')).to have_content(token[:value]) if token[:value].present? # gl-emoji content is blank when the emoji unicode is not supported @@ -97,8 +102,8 @@ module FilteredSearchHelpers end end - def create_token(token_name, token_value = nil, symbol = nil) - { name: token_name, value: "#{symbol}#{token_value}" } + def create_token(token_name, token_value = nil, symbol = nil, token_operator = '=') + { name: token_name, operator: token_operator, value: "#{symbol}#{token_value}" } end def author_token(author_name = nil) @@ -109,9 +114,9 @@ module FilteredSearchHelpers create_token('Assignee', assignee_name) end - def milestone_token(milestone_name = nil, has_symbol = true) + def milestone_token(milestone_name = nil, has_symbol = true, operator = '=') symbol = has_symbol ? '%' : nil - create_token('Milestone', milestone_name, symbol) + create_token('Milestone', milestone_name, symbol, operator) end def release_token(release_tag = nil) diff --git a/spec/support/helpers/graphql_helpers.rb b/spec/support/helpers/graphql_helpers.rb index e21b3aea3da25d81f4ccc68c15c0e902f424697e..6d9c27d0255901099bdd5422b10c20484d392521 100644 --- a/spec/support/helpers/graphql_helpers.rb +++ b/spec/support/helpers/graphql_helpers.rb @@ -105,12 +105,15 @@ module GraphqlHelpers end def query_graphql_field(name, attributes = {}, fields = nil) - fields ||= all_graphql_fields_for(name.classify) - attributes = attributes_to_graphql(attributes) - attributes = "(#{attributes})" if attributes.present? + field_params = if attributes.present? + "(#{attributes_to_graphql(attributes)})" + else + '' + end + <<~QUERY - #{name}#{attributes} - #{wrap_fields(fields)} + #{GraphqlHelpers.fieldnamerize(name.to_s)}#{field_params} + #{wrap_fields(fields || all_graphql_fields_for(name.to_s.classify))} QUERY end @@ -301,6 +304,17 @@ module GraphqlHelpers def global_id_of(model) model.to_global_id.to_s end + + def missing_required_argument(path, argument) + a_hash_including( + 'path' => ['query'].concat(path), + 'extensions' => a_hash_including('code' => 'missingRequiredArguments', 'arguments' => argument.to_s) + ) + end + + def custom_graphql_error(path, msg) + a_hash_including('path' => path, 'message' => msg) + end end # This warms our schema, doing this as part of loading the helpers to avoid diff --git a/spec/support/helpers/kubernetes_helpers.rb b/spec/support/helpers/kubernetes_helpers.rb index 9435a0e1487273d3808d5e847c7d8cd071456a7f..89360b55de2b456ac90237674643d176f6fdd921 100644 --- a/spec/support/helpers/kubernetes_helpers.rb +++ b/spec/support/helpers/kubernetes_helpers.rb @@ -33,6 +33,14 @@ module KubernetesHelpers .to_return(kube_response(kube_v1_rbac_authorization_discovery_body)) end + def stub_kubeclient_discover_istio(api_url) + stub_kubeclient_discover_base(api_url) + + WebMock + .stub_request(:get, api_url + '/apis/networking.istio.io/v1alpha3') + .to_return(kube_response(kube_istio_discovery_body)) + end + def stub_kubeclient_discover(api_url) stub_kubeclient_discover_base(api_url) @@ -229,6 +237,16 @@ module KubernetesHelpers .to_return(kube_response({})) end + def stub_kubeclient_get_gateway(api_url, name, namespace: 'default') + WebMock.stub_request(:get, api_url + "/apis/networking.istio.io/v1alpha3/namespaces/#{namespace}/gateways/#{name}") + .to_return(kube_response(kube_istio_gateway_body(name, namespace))) + end + + def stub_kubeclient_put_gateway(api_url, name, namespace: 'default') + WebMock.stub_request(:put, api_url + "/apis/networking.istio.io/v1alpha3/namespaces/#{namespace}/gateways/#{name}") + .to_return(kube_response({})) + end + def kube_v1_secret_body(**options) { "kind" => "SecretList", @@ -282,6 +300,115 @@ module KubernetesHelpers } end + def kube_istio_discovery_body + { + "kind" => "APIResourceList", + "apiVersion" => "v1", + "groupVersion" => "networking.istio.io/v1alpha3", + "resources" => [ + { + "name" => "gateways", + "singularName" => "gateway", + "namespaced" => true, + "kind" => "Gateway", + "verbs" => %w[delete deletecollection get list patch create update watch], + "shortNames" => %w[gw], + "categories" => %w[istio-io networking-istio-io] + }, + { + "name" => "serviceentries", + "singularName" => "serviceentry", + "namespaced" => true, + "kind" => "ServiceEntry", + "verbs" => %w[delete deletecollection get list patch create update watch], + "shortNames" => %w[se], + "categories" => %w[istio-io networking-istio-io] + }, + { + "name" => "destinationrules", + "singularName" => "destinationrule", + "namespaced" => true, + "kind" => "DestinationRule", + "verbs" => %w[delete deletecollection get list patch create update watch], + "shortNames" => %w[dr], + "categories" => %w[istio-io networking-istio-io] + }, + { + "name" => "envoyfilters", + "singularName" => "envoyfilter", + "namespaced" => true, + "kind" => "EnvoyFilter", + "verbs" => %w[delete deletecollection get list patch create update watch], + "categories" => %w[istio-io networking-istio-io] + }, + { + "name" => "sidecars", + "singularName" => "sidecar", + "namespaced" => true, + "kind" => "Sidecar", + "verbs" => %w[delete deletecollection get list patch create update watch], + "categories" => %w[istio-io networking-istio-io] + }, + { + "name" => "virtualservices", + "singularName" => "virtualservice", + "namespaced" => true, + "kind" => "VirtualService", + "verbs" => %w[delete deletecollection get list patch create update watch], + "shortNames" => %w[vs], + "categories" => %w[istio-io networking-istio-io] + } + ] + } + end + + def kube_istio_gateway_body(name, namespace) + { + "apiVersion" => "networking.istio.io/v1alpha3", + "kind" => "Gateway", + "metadata" => { + "generation" => 1, + "labels" => { + "networking.knative.dev/ingress-provider" => "istio", + "serving.knative.dev/release" => "v0.7.0" + }, + "name" => name, + "namespace" => namespace, + "selfLink" => "/apis/networking.istio.io/v1alpha3/namespaces/#{namespace}/gateways/#{name}" + }, + "spec" => { + "selector" => { + "istio" => "ingressgateway" + }, + "servers" => [ + { + "hosts" => [ + "*" + ], + "port" => { + "name" => "http", + "number" => 80, + "protocol" => "HTTP" + } + }, + { + "hosts" => [ + "*" + ], + "port" => { + "name" => "https", + "number" => 443, + "protocol" => "HTTPS" + }, + "tls" => { + "mode" => "PASSTHROUGH" + } + } + ] + } + } + end + def kube_v1alpha1_serving_knative_discovery_body { "kind" => "APIResourceList", diff --git a/spec/support/helpers/metrics_dashboard_helpers.rb b/spec/support/helpers/metrics_dashboard_helpers.rb index 5b425d0964d4eef525888d3a9c93db878f0c2d5c..908a3e1fb094f9efad4138dc35d26b773c2523bc 100644 --- a/spec/support/helpers/metrics_dashboard_helpers.rb +++ b/spec/support/helpers/metrics_dashboard_helpers.rb @@ -29,54 +29,4 @@ module MetricsDashboardHelpers def business_metric_title PrometheusMetricEnums.group_details[:business][:group_title] end - - shared_examples_for 'misconfigured dashboard service response' do |status_code| - it 'returns an appropriate message and status code' do - result = service_call - - expect(result.keys).to contain_exactly(:message, :http_status, :status) - expect(result[:status]).to eq(:error) - expect(result[:http_status]).to eq(status_code) - end - end - - shared_examples_for 'valid dashboard service response for schema' do - it 'returns a json representation of the dashboard' do - result = service_call - - expect(result.keys).to contain_exactly(:dashboard, :status) - expect(result[:status]).to eq(:success) - - expect(JSON::Validator.fully_validate(dashboard_schema, result[:dashboard])).to be_empty - end - end - - shared_examples_for 'valid dashboard service response' do - let(:dashboard_schema) { JSON.parse(fixture_file('lib/gitlab/metrics/dashboard/schemas/dashboard.json')) } - - it_behaves_like 'valid dashboard service response for schema' - end - - shared_examples_for 'caches the unprocessed dashboard for subsequent calls' do - it do - expect(YAML).to receive(:safe_load).once.and_call_original - - described_class.new(*service_params).get_dashboard - described_class.new(*service_params).get_dashboard - end - end - - shared_examples_for 'valid embedded dashboard service response' do - let(:dashboard_schema) { JSON.parse(fixture_file('lib/gitlab/metrics/dashboard/schemas/embedded_dashboard.json')) } - - it_behaves_like 'valid dashboard service response for schema' - end - - shared_examples_for 'raises error for users with insufficient permissions' do - context 'when the user does not have sufficient access' do - let(:user) { build(:user) } - - it_behaves_like 'misconfigured dashboard service response', :unauthorized - end - end end diff --git a/spec/support/helpers/query_recorder.rb b/spec/support/helpers/query_recorder.rb index 9d47a0c23df327ce64ffb942bbdf185350306705..1d04014c9a697a20755383fc8753e7a0bfdb1a8e 100644 --- a/spec/support/helpers/query_recorder.rb +++ b/spec/support/helpers/query_recorder.rb @@ -16,7 +16,7 @@ module ActiveRecord def show_backtrace(values) Rails.logger.debug("QueryRecorder SQL: #{values[:sql]}") - Gitlab::Profiler.clean_backtrace(caller).each { |line| Rails.logger.debug(" --> #{line}") } + Gitlab::BacktraceCleaner.clean_backtrace(caller).each { |line| Rails.logger.debug(" --> #{line}") } end def callback(name, start, finish, message_id, values) diff --git a/spec/support/helpers/sentry_client_helpers.rb b/spec/support/helpers/sentry_client_helpers.rb index 7476b5fb24961439bb82786357cf7b22bceb09c5..d473fe89feecfc09bc52d46274f92dd4ed8f6c70 100644 --- a/spec/support/helpers/sentry_client_helpers.rb +++ b/spec/support/helpers/sentry_client_helpers.rb @@ -3,8 +3,8 @@ module SentryClientHelpers private - def stub_sentry_request(url, body: {}, status: 200, headers: {}) - stub_request(:get, url) + def stub_sentry_request(url, http_method = :get, body: {}, status: 200, headers: {}) + stub_request(http_method, url) .to_return( status: status, headers: { 'Content-Type' => 'application/json' }.merge(headers), diff --git a/spec/support/helpers/test_env.rb b/spec/support/helpers/test_env.rb index 6a23875f1031874d38cb46666528d53f63fd09d9..bd945fe6409d70a3985184c67210bdd519440f85 100644 --- a/spec/support/helpers/test_env.rb +++ b/spec/support/helpers/test_env.rb @@ -154,7 +154,6 @@ module TestEnv install_dir: gitaly_dir, version: Gitlab::GitalyClient.expected_server_version, task: "gitlab:gitaly:install[#{install_gitaly_args}]") do - Gitlab::SetupHelper.create_gitaly_configuration(gitaly_dir, { 'default' => repos_path }, force: true) start_gitaly(gitaly_dir) end @@ -246,8 +245,8 @@ module TestEnv end end - def copy_repo(project, bare_repo:, refs:) - target_repo_path = File.expand_path(repos_path + "/#{project.disk_path}.git") + def copy_repo(subject, bare_repo:, refs:) + target_repo_path = File.expand_path(repos_path + "/#{subject.disk_path}.git") FileUtils.mkdir_p(target_repo_path) FileUtils.cp_r("#{File.expand_path(bare_repo)}/.", target_repo_path) diff --git a/spec/support/import_export/common_util.rb b/spec/support/import_export/common_util.rb index 4e149c9fa5464facbd4d71169e571288da85e47e..72baec7bfcb7bdf0260e527f29d1a5d5648abd98 100644 --- a/spec/support/import_export/common_util.rb +++ b/spec/support/import_export/common_util.rb @@ -3,7 +3,9 @@ module ImportExport module CommonUtil def setup_symlink(tmpdir, symlink_name) - allow_any_instance_of(Gitlab::ImportExport).to receive(:storage_path).and_return(tmpdir) + allow_next_instance_of(Gitlab::ImportExport) do |instance| + allow(instance).to receive(:storage_path).and_return(tmpdir) + end File.open("#{tmpdir}/test", 'w') { |file| file.write("test") } FileUtils.ln_s("#{tmpdir}/test", "#{tmpdir}/#{symlink_name}") diff --git a/spec/support/import_export/configuration_helper.rb b/spec/support/import_export/configuration_helper.rb index 2e5a99bb8b26cadd4c60f630f09f4e1ab7dd2560..27819b5201a2d3f86b740dd94afda9bf8a898570 100644 --- a/spec/support/import_export/configuration_helper.rb +++ b/spec/support/import_export/configuration_helper.rb @@ -36,8 +36,8 @@ module ConfigurationHelper end def relation_class_for_name(relation_name) - relation_name = Gitlab::ImportExport::RelationFactory.overrides[relation_name.to_sym] || relation_name - Gitlab::ImportExport::RelationFactory.relation_class(relation_name) + relation_name = Gitlab::ImportExport::ProjectRelationFactory.overrides[relation_name.to_sym] || relation_name + Gitlab::ImportExport::ProjectRelationFactory.relation_class(relation_name) end def parsed_attributes(relation_name, attributes, config: Gitlab::ImportExport.config_file) diff --git a/spec/support/matchers/eq_uri.rb b/spec/support/matchers/eq_uri.rb new file mode 100644 index 0000000000000000000000000000000000000000..47b657b3fe19fed52f06e6cc8c3d74d97759529d --- /dev/null +++ b/spec/support/matchers/eq_uri.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +# Assert the result matches a URI object initialized with the expectation variable. +# +# Success: +# ``` +# expect(URI('www.fish.com')).to eq_uri('www.fish.com') +# ``` +# +# Failure: +# ``` +# expect(URI('www.fish.com')).to eq_uri('www.dog.com') +# ``` +# +RSpec::Matchers.define :eq_uri do |expected| + match do |actual| + actual == URI(expected) + end +end diff --git a/spec/support/matchers/graphql_matchers.rb b/spec/support/matchers/graphql_matchers.rb index dbf457a92001abef5d22369d21dedd686aee356d..e151a9345910a7c57f3d2dcc1b4f8bae1760a50e 100644 --- a/spec/support/matchers/graphql_matchers.rb +++ b/spec/support/matchers/graphql_matchers.rb @@ -8,11 +8,25 @@ end RSpec::Matchers.define :have_graphql_fields do |*expected| def expected_field_names - expected.map { |name| GraphqlHelpers.fieldnamerize(name) } + Array.wrap(expected).map { |name| GraphqlHelpers.fieldnamerize(name) } + end + + @allow_extra = false + + chain :only do + @allow_extra = false + end + + chain :at_least do + @allow_extra = true end match do |kls| - expect(kls.fields.keys).to contain_exactly(*expected_field_names) + if @allow_extra + expect(kls.fields.keys).to include(*expected_field_names) + else + expect(kls.fields.keys).to contain_exactly(*expected_field_names) + end end failure_message do |kls| @@ -22,7 +36,7 @@ RSpec::Matchers.define :have_graphql_fields do |*expected| message = [] message << "is missing fields: <#{missing.inspect}>" if missing.any? - message << "contained unexpected fields: <#{extra.inspect}>" if extra.any? + message << "contained unexpected fields: <#{extra.inspect}>" if extra.any? && !@allow_extra message.join("\n") end diff --git a/spec/support/matchers/markdown_matchers.rb b/spec/support/matchers/markdown_matchers.rb index 35b2993443faa288a9a61f08fecb1520127ca2ab..103019d8dd85094a77c5b6ae6f02430dd6ab9003 100644 --- a/spec/support/matchers/markdown_matchers.rb +++ b/spec/support/matchers/markdown_matchers.rb @@ -10,8 +10,21 @@ module MarkdownMatchers extend RSpec::Matchers::DSL include Capybara::Node::Matchers - # RelativeLinkFilter - matcher :parse_relative_links do + # UploadLinkFilter + matcher :parse_upload_links do + set_default_markdown_messages + + match do |actual| + link = actual.at_css('a:contains("Relative Upload Link")') + image = actual.at_css('img[alt="Relative Upload Image"]') + + expect(link['href']).to eq("/#{project.full_path}/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg") + expect(image['data-src']).to eq("/#{project.full_path}/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg") + end + end + + # RepositoryLinkFilter + matcher :parse_repository_links do set_default_markdown_messages match do |actual| diff --git a/spec/support/migrations_helpers/prometheus_service_helpers.rb b/spec/support/migrations_helpers/prometheus_service_helpers.rb new file mode 100644 index 0000000000000000000000000000000000000000..88f2f71ee1e9b483a98d99bda0b4458df19cb7dd --- /dev/null +++ b/spec/support/migrations_helpers/prometheus_service_helpers.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module MigrationHelpers + module PrometheusServiceHelpers + def service_params_for(project_id, params = {}) + { + project_id: project_id, + active: false, + properties: '{}', + type: 'PrometheusService', + template: false, + push_events: true, + issues_events: true, + merge_requests_events: true, + tag_push_events: true, + note_events: true, + category: 'monitoring', + default: false, + wiki_page_events: true, + pipeline_events: true, + confidential_issues_events: true, + commit_events: true, + job_events: true, + confidential_note_events: true, + deployment_events: false + }.merge(params) + end + + def row_attributes(entity) + entity.attributes.with_indifferent_access.tap do |hash| + hash.merge!(hash.slice(:created_at, :updated_at).transform_values { |v| v.to_s(:db) }) + end + end + end +end diff --git a/spec/support/prometheus/additional_metrics_shared_examples.rb b/spec/support/prometheus/additional_metrics_shared_examples.rb index 4e006edb7daf75b244759301102a10d4e60bb74d..3a5909cd908bce909e06c511dcfdf89d9c433de9 100644 --- a/spec/support/prometheus/additional_metrics_shared_examples.rb +++ b/spec/support/prometheus/additional_metrics_shared_examples.rb @@ -14,7 +14,7 @@ RSpec.shared_examples 'additional metrics query' do let(:client) { double('prometheus_client') } let(:query_result) { described_class.new(client).query(*query_params) } - let(:project) { create(:project) } + let(:project) { create(:project, :repository) } let(:environment) { create(:environment, slug: 'environment-slug', project: project) } before do @@ -47,8 +47,7 @@ RSpec.shared_examples 'additional metrics query' do describe 'project has Kubernetes service' do context 'when user configured kubernetes from CI/CD > Clusters' do - let!(:cluster) { create(:cluster, :project, :provided_by_gcp) } - let(:project) { cluster.project } + let!(:cluster) { create(:cluster, :project, :provided_by_gcp, projects: [project]) } let(:environment) { create(:environment, slug: 'environment-slug', project: project) } let(:kube_namespace) { environment.deployment_namespace } diff --git a/spec/support/redis/redis_shared_examples.rb b/spec/support/redis/redis_shared_examples.rb index 97a23f02b3e4f68722590631551808d0767899b5..1e2d11a66cbf3f608df625416cf6d5549eba3edd 100644 --- a/spec/support/redis/redis_shared_examples.rb +++ b/spec/support/redis/redis_shared_examples.rb @@ -116,9 +116,9 @@ RSpec.shared_examples "redis_shared_examples" do clear_pool end - context 'when running not on sidekiq workers' do + context 'when running on single-threaded runtime' do before do - allow(Sidekiq).to receive(:server?).and_return(false) + allow(Gitlab::Runtime).to receive(:multi_threaded?).and_return(false) end it 'instantiates a connection pool with size 5' do @@ -128,10 +128,10 @@ RSpec.shared_examples "redis_shared_examples" do end end - context 'when running on sidekiq workers' do + context 'when running on multi-threaded runtime' do before do - allow(Sidekiq).to receive(:server?).and_return(true) - allow(Sidekiq).to receive(:options).and_return({ concurrency: 18 }) + allow(Gitlab::Runtime).to receive(:multi_threaded?).and_return(true) + allow(Gitlab::Runtime).to receive(:max_threads).and_return(18) end it 'instantiates a connection pool with a size based on the concurrency of the worker' do diff --git a/spec/support/shared_contexts/upload_type_check_shared_context.rb b/spec/support/shared_contexts/upload_type_check_shared_context.rb new file mode 100644 index 0000000000000000000000000000000000000000..04c97500dd62ebf5f768c74204b1c7ef74db1f28 --- /dev/null +++ b/spec/support/shared_contexts/upload_type_check_shared_context.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +# Construct an `uploader` variable that is configured to `check_upload_type` +# with `mime_types` and `extensions`. +shared_context 'uploader with type check' do + let(:uploader_class) do + Class.new(GitlabUploader) do + include UploadTypeCheck::Concern + storage :file + end + end + + let(:mime_types) { nil } + let(:extensions) { nil } + let(:uploader) do + uploader_class.class_exec(mime_types, extensions) do |mime_types, extensions| + check_upload_type mime_types: mime_types, extensions: extensions + end + uploader_class.new(build_stubbed(:user)) + end +end + +shared_context 'stubbed MimeMagic mime type detection' do + let(:mime_type) { '' } + let(:magic_mime) { mime_type } + let(:ext_mime) { mime_type } + before do + magic_mime_obj = MimeMagic.new(magic_mime) + ext_mime_obj = MimeMagic.new(ext_mime) + allow(MimeMagic).to receive(:by_magic).with(anything).and_return(magic_mime_obj) + allow(MimeMagic).to receive(:by_path).with(anything).and_return(ext_mime_obj) + end +end diff --git a/spec/support/shared_examples/controllers/error_tracking_shared_examples.rb b/spec/support/shared_examples/controllers/error_tracking_shared_examples.rb new file mode 100644 index 0000000000000000000000000000000000000000..71251f6ab5143bf728096784c996cae904e24a3e --- /dev/null +++ b/spec/support/shared_examples/controllers/error_tracking_shared_examples.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +shared_examples 'sets the polling header' do + subject { response.headers[Gitlab::PollingInterval::HEADER_NAME] } + + it { is_expected.to eq '1000'} +end diff --git a/spec/support/shared_examples/email_shared_examples.rb b/spec/support/shared_examples/email_shared_examples.rb new file mode 100644 index 0000000000000000000000000000000000000000..634a25047668475d7784426ec543219f70e316e9 --- /dev/null +++ b/spec/support/shared_examples/email_shared_examples.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +shared_examples_for 'correctly finds the mail key' do + specify do + expect(Gitlab::Email::Handler).to receive(:for).with(an_instance_of(Mail::Message), 'gitlabhq/gitlabhq+auth_token').and_return(handler) + + receiver.execute + end +end diff --git a/spec/support/shared_examples/features/issuables_user_dropdown_behaviors_shared_examples.rb b/spec/support/shared_examples/features/issuables_user_dropdown_behaviors_shared_examples.rb index 63ed37cde039c51f36c3cb0dbe5c3daac77dbe18..3da80541072ada51bb829b4fef827784ef8a9b1a 100644 --- a/spec/support/shared_examples/features/issuables_user_dropdown_behaviors_shared_examples.rb +++ b/spec/support/shared_examples/features/issuables_user_dropdown_behaviors_shared_examples.rb @@ -13,7 +13,7 @@ shared_examples 'issuable user dropdown behaviors' do it 'only includes members of the project/group' do visit issuables_path - filtered_search.set("#{dropdown}:") + filtered_search.set("#{dropdown}=") expect(find("#js-dropdown-#{dropdown} .filter-dropdown")).to have_content(user_in_dropdown.name) expect(find("#js-dropdown-#{dropdown} .filter-dropdown")).not_to have_content(user_not_in_dropdown.name) diff --git a/spec/support/shared_examples/features/master_manages_access_requests_shared_example.rb b/spec/support/shared_examples/features/master_manages_access_requests_shared_example.rb index 8e1d24c4be2e086fb1148e54d4dea555f4596ff5..98010150e6547c73d449375200ed64fa5207f971 100644 --- a/spec/support/shared_examples/features/master_manages_access_requests_shared_example.rb +++ b/spec/support/shared_examples/features/master_manages_access_requests_shared_example.rb @@ -25,7 +25,7 @@ RSpec.shared_examples 'Maintainer manages access requests' do expect_no_visible_access_request(entity, user) - page.within('.members-list') do + page.within('[data-qa-selector="members_list"]') do expect(page).to have_content user.name end end diff --git a/spec/support/shared_examples/graphql/connection_paged_nodes.rb b/spec/support/shared_examples/graphql/connection_paged_nodes.rb index 830d2d2d4b113a8f861e38a30a0030721c4ec0be..93de7f619f7cb112dadf3f6f5f919545deb11c1f 100644 --- a/spec/support/shared_examples/graphql/connection_paged_nodes.rb +++ b/spec/support/shared_examples/graphql/connection_paged_nodes.rb @@ -2,7 +2,7 @@ RSpec.shared_examples 'connection with paged nodes' do it 'returns the collection limited to max page size' do - expect(paged_nodes.size).to eq(3) + expect(paged_nodes.size).to eq(paged_nodes_size) end it 'is a loaded memoized array' do @@ -22,7 +22,7 @@ RSpec.shared_examples 'connection with paged nodes' do let(:arguments) { { last: 2 } } it 'returns only the last elements' do - expect(paged_nodes).to contain_exactly(all_nodes[3], all_nodes[4]) + expect(paged_nodes).to contain_exactly(*all_nodes.last(2)) end end end diff --git a/spec/support/shared_examples/graphql/failure_to_find_anything.rb b/spec/support/shared_examples/graphql/failure_to_find_anything.rb new file mode 100644 index 0000000000000000000000000000000000000000..b2533c992c145fb2b7748b5cccf56553603956bf --- /dev/null +++ b/spec/support/shared_examples/graphql/failure_to_find_anything.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require 'spec_helper' + +# Shared example for legal queries that are expected to return nil. +# Requires the following let bindings to be defined: +# - post_query: action to send the query +# - path: array of keys from query root to the result +shared_examples 'a failure to find anything' do + it 'finds nothing' do + post_query + + data = graphql_data.dig(*path) + + expect(data).to be_nil + end +end diff --git a/spec/support/shared_examples/lib/gitlab/import_export/import_failure_service_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/import_export/import_failure_service_shared_examples.rb new file mode 100644 index 0000000000000000000000000000000000000000..691564120cc0bb965df51fa6aa164298610a9d50 --- /dev/null +++ b/spec/support/shared_examples/lib/gitlab/import_export/import_failure_service_shared_examples.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +shared_examples 'log import failure' do |importable_column| + it 'tracks error' do + extra = { + relation_key: relation_key, + relation_index: relation_index, + retry_count: retry_count + } + extra[importable_column] = importable.id + + expect(Gitlab::ErrorTracking).to receive(:track_exception).with(exception, extra) + + subject.log_import_failure(relation_key, relation_index, exception, retry_count) + end + + it 'saves data to ImportFailure' do + log_import_failure + + import_failure = ImportFailure.last + + aggregate_failures do + expect(import_failure[importable_column]).to eq(importable.id) + expect(import_failure.relation_key).to eq(relation_key) + expect(import_failure.relation_index).to eq(relation_index) + expect(import_failure.exception_class).to eq('StandardError') + expect(import_failure.exception_message).to eq(standard_error_message) + expect(import_failure.correlation_id_value).to eq(correlation_id) + expect(import_failure.retry_count).to eq(retry_count) + end + end +end diff --git a/spec/support/shared_examples/lib/sentry/client_shared_examples.rb b/spec/support/shared_examples/lib/sentry/client_shared_examples.rb index 76b71ebd3c575a04c10096c0c63ae9f0802eb98f..4221708b55c83409122f5930c685d3ef7350f48d 100644 --- a/spec/support/shared_examples/lib/sentry/client_shared_examples.rb +++ b/spec/support/shared_examples/lib/sentry/client_shared_examples.rb @@ -10,7 +10,7 @@ RSpec.shared_examples 'calls sentry api' do end # Requires sentry_api_url and subject to be defined -RSpec.shared_examples 'no Sentry redirects' do +RSpec.shared_examples 'no Sentry redirects' do |http_method| let(:redirect_to) { 'https://redirected.example.com' } let(:other_url) { 'https://other.example.org' } @@ -19,6 +19,7 @@ RSpec.shared_examples 'no Sentry redirects' do let!(:redirect_req_stub) do stub_sentry_request( sentry_api_url, + http_method || :get, status: 302, headers: { location: redirect_to } ) @@ -31,7 +32,7 @@ RSpec.shared_examples 'no Sentry redirects' do end end -RSpec.shared_examples 'maps Sentry exceptions' do +RSpec.shared_examples 'maps Sentry exceptions' do |http_method| exceptions = { Gitlab::HTTP::Error => 'Error when connecting to Sentry', Net::OpenTimeout => 'Connection to Sentry timed out', @@ -44,7 +45,10 @@ RSpec.shared_examples 'maps Sentry exceptions' do exceptions.each do |exception, message| context "#{exception}" do before do - stub_request(:get, sentry_request_url).to_raise(exception) + stub_request( + http_method || :get, + sentry_request_url + ).to_raise(exception) end it do diff --git a/spec/support/shared_examples/logging_application_context_shared_examples.rb b/spec/support/shared_examples/logging_application_context_shared_examples.rb new file mode 100644 index 0000000000000000000000000000000000000000..038ede884c88c403b395ec6c0c997fee9e9dbc16 --- /dev/null +++ b/spec/support/shared_examples/logging_application_context_shared_examples.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'storing arguments in the application context' do + around do |example| + Labkit::Context.with_context { example.run } + end + + it 'places the expected params in the application context' do + # Stub the clearing of the context so we can validate it later + # The `around` block above makes sure we do clean it up later + allow(Labkit::Context).to receive(:pop) + + subject + + Labkit::Context.with_context do |context| + expect(context.to_h) + .to include(log_hash(expected_params)) + end + end + + def log_hash(hash) + hash.transform_keys! { |key| "meta.#{key}" } + end +end diff --git a/spec/support/shared_examples/merge_requests_rendering_a_single_diff_version.rb b/spec/support/shared_examples/merge_requests_rendering_a_single_diff_version.rb deleted file mode 100644 index 18d025a4b075ccf89371983b24ae581c5afba277..0000000000000000000000000000000000000000 --- a/spec/support/shared_examples/merge_requests_rendering_a_single_diff_version.rb +++ /dev/null @@ -1,21 +0,0 @@ -# frozen_string_literal: true - -# This pending test can be removed when `single_mr_diff_view` is enabled by default -# disabling the feature flag above is then not needed anymore. -RSpec.shared_examples 'rendering a single diff version' do |attribute| - before do - stub_feature_flags(diffs_batch_load: false) - end - - pending 'allows editing diff settings single_mr_diff_view is enabled' do - project = create(:project, :repository) - user = project.creator - merge_request = create(:merge_request, source_project: project) - stub_feature_flags(single_mr_diff_view: true) - sign_in(user) - - visit(diffs_project_merge_request_path(project, merge_request)) - - expect(page).to have_selector('.js-show-diff-settings') - end -end diff --git a/spec/support/shared_examples/migration_helpers_examples.rb b/spec/support/shared_examples/migration_helpers_examples.rb new file mode 100644 index 0000000000000000000000000000000000000000..3587297a2d756814308fdbe0535b8fd19fb00835 --- /dev/null +++ b/spec/support/shared_examples/migration_helpers_examples.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +shared_examples 'skips validation' do |validation_option| + it 'skips validation' do + expect(model).not_to receive(:disable_statement_timeout) + expect(model).to receive(:execute).with(/ADD CONSTRAINT/) + expect(model).not_to receive(:execute).with(/VALIDATE CONSTRAINT/) + + model.add_concurrent_foreign_key(*args, **options.merge(validation_option)) + end +end + +shared_examples 'performs validation' do |validation_option| + it 'performs validation' do + expect(model).to receive(:disable_statement_timeout).and_call_original + expect(model).to receive(:execute).with(/statement_timeout/) + expect(model).to receive(:execute).ordered.with(/NOT VALID/) + expect(model).to receive(:execute).ordered.with(/VALIDATE CONSTRAINT/) + expect(model).to receive(:execute).with(/RESET ALL/) + + model.add_concurrent_foreign_key(*args, **options.merge(validation_option)) + end +end diff --git a/spec/support/shared_examples/models/cluster_application_initial_status.rb b/spec/support/shared_examples/models/cluster_application_initial_status.rb index 9775d87953c2e58275562f600964b92ec77ca563..030974c9aa075c391e9cf7cb427808c286864156 100644 --- a/spec/support/shared_examples/models/cluster_application_initial_status.rb +++ b/spec/support/shared_examples/models/cluster_application_initial_status.rb @@ -6,8 +6,30 @@ shared_examples 'cluster application initial status specs' do subject { described_class.new(cluster: cluster) } + context 'local tiller feature flag is disabled' do + before do + stub_feature_flags(managed_apps_local_tiller: false) + end + + it 'sets a default status' do + expect(subject.status_name).to be(:not_installable) + end + end + + context 'local tiller feature flag is enabled' do + before do + stub_feature_flags(managed_apps_local_tiller: true) + end + + it 'sets a default status' do + expect(subject.status_name).to be(:installable) + end + end + context 'when application helm is scheduled' do before do + stub_feature_flags(managed_apps_local_tiller: false) + create(:clusters_applications_helm, :scheduled, cluster: cluster) end @@ -16,7 +38,7 @@ shared_examples 'cluster application initial status specs' do end end - context 'when application is scheduled' do + context 'when application helm is installed' do before do create(:clusters_applications_helm, :installed, cluster: cluster) end diff --git a/spec/support/shared_examples/models/cluster_application_status_shared_examples.rb b/spec/support/shared_examples/models/cluster_application_status_shared_examples.rb index a6653f89377be2323c865f768c7cdb9cfce98583..4bca37a4cd08b16862385081ab09575487ea3db4 100644 --- a/spec/support/shared_examples/models/cluster_application_status_shared_examples.rb +++ b/spec/support/shared_examples/models/cluster_application_status_shared_examples.rb @@ -1,16 +1,6 @@ # frozen_string_literal: true shared_examples 'cluster application status specs' do |application_name| - describe '#status' do - let(:cluster) { create(:cluster, :provided_by_gcp) } - - subject { described_class.new(cluster: cluster) } - - it 'sets a default status' do - expect(subject.status_name).to be(:not_installable) - end - end - describe '#status_states' do let(:cluster) { create(:cluster, :provided_by_gcp) } diff --git a/spec/support/shared_examples/models/diff_note_after_commit_shared_examples.rb b/spec/support/shared_examples/models/diff_note_after_commit_shared_examples.rb new file mode 100644 index 0000000000000000000000000000000000000000..835d2dfe757976b49a2cec4ce5cdb5abd61d82c9 --- /dev/null +++ b/spec/support/shared_examples/models/diff_note_after_commit_shared_examples.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +shared_examples 'a valid diff note with after commit callback' do + context 'when diff file is fetched from repository' do + before do + allow_any_instance_of(::Gitlab::Diff::Position).to receive(:diff_file).with(project.repository).and_return(diff_file_from_repository) + end + + context 'when diff_line is not found' do + it 'raises an error' do + allow(diff_file_from_repository).to receive(:line_for_position).with(position).and_return(nil) + + expect { subject.save }.to raise_error(::DiffNote::NoteDiffFileCreationError, + "Failed to find diff line for: #{diff_file_from_repository.file_path}, "\ + "old_line: #{position.old_line}"\ + ", new_line: #{position.new_line}") + end + end + + context 'when diff_line is found' do + before do + allow(diff_file_from_repository).to receive(:line_for_position).with(position).and_return(diff_line) + end + + it 'fallback to fetch file from repository' do + expect_any_instance_of(::Gitlab::Diff::Position).to receive(:diff_file).with(project.repository) + + subject.save + end + + it 'creates a diff note file' do + subject.save + + expect(subject.reload.note_diff_file).to be_present + end + end + end + + context 'when diff file is not found in repository' do + it 'raises an error' do + allow_any_instance_of(::Gitlab::Diff::Position).to receive(:diff_file).with(project.repository).and_return(nil) + + expect { subject.save }.to raise_error(::DiffNote::NoteDiffFileCreationError, 'Failed to find diff file') + end + end +end diff --git a/spec/support/shared_examples/pages_size_limit_shared_examples.rb b/spec/support/shared_examples/pages_size_limit_shared_examples.rb new file mode 100644 index 0000000000000000000000000000000000000000..c1e27194738c6d818be565fdb91b90d12e7e6146 --- /dev/null +++ b/spec/support/shared_examples/pages_size_limit_shared_examples.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +shared_examples 'pages size limit is' do |size_limit| + context "when size is below the limit" do + before do + allow(metadata).to receive(:total_size).and_return(size_limit - 1.megabyte) + end + + it 'updates pages correctly' do + subject.execute + + expect(deploy_status.description).not_to be_present + expect(project.pages_metadatum).to be_deployed + end + end + + context "when size is above the limit" do + before do + allow(metadata).to receive(:total_size).and_return(size_limit + 1.megabyte) + end + + it 'limits the maximum size of gitlab pages' do + subject.execute + + expect(deploy_status.description) + .to match(/artifacts for pages are too large/) + expect(deploy_status).to be_script_failure + end + end +end diff --git a/spec/support/shared_examples/requests/api/diff_discussions.rb b/spec/support/shared_examples/requests/api/diff_discussions.rb index 76c6c93964a26d7db183aae8c4b1b5efcd2dfd06..a7774d17d3c8f9be72c01b039280ef9a53c247a1 100644 --- a/spec/support/shared_examples/requests/api/diff_discussions.rb +++ b/spec/support/shared_examples/requests/api/diff_discussions.rb @@ -38,13 +38,24 @@ shared_examples 'diff discussions API' do |parent_type, noteable_type, id_name| expect(json_response['notes'].first['position']).to eq(position.stringify_keys) end - it "returns a 400 bad request error when position is invalid" do - position = diff_note.position.to_h.merge(new_line: '100000') + context "when position is invalid" do + it "returns a 400 bad request error when position is not plausible" do + position = diff_note.position.to_h.merge(new_line: '100000') - post api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/discussions", user), - params: { body: 'hi!', position: position } + post api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/discussions", user), + params: { body: 'hi!', position: position } + + expect(response).to have_gitlab_http_status(400) + end + + it "returns a 400 bad request error when the position is not valid for this discussion" do + position = diff_note.position.to_h.merge(new_line: '588440f66559714280628a4f9799f0c4eb880a4a') + + post api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/discussions", user), + params: { body: 'hi!', position: position } - expect(response).to have_gitlab_http_status(400) + expect(response).to have_gitlab_http_status(400) + end end end diff --git a/spec/support/shared_examples/requests/api/status_shared_examples.rb b/spec/support/shared_examples/requests/api/status_shared_examples.rb index eebed7e42c13ed90b4a1168ad50d40300d4c51ed..ed9964fa10898cdd140c3ea85217167be3da3c2f 100644 --- a/spec/support/shared_examples/requests/api/status_shared_examples.rb +++ b/spec/support/shared_examples/requests/api/status_shared_examples.rb @@ -59,8 +59,9 @@ shared_examples_for '412 response' do delete request, params: params, headers: { 'HTTP_IF_UNMODIFIED_SINCE' => '1990-01-12T00:00:48-0600' } end - it 'returns 412' do + it 'returns 412 with a JSON error' do expect(response).to have_gitlab_http_status(412) + expect(json_response).to eq('message' => '412 Precondition Failed') end end @@ -69,8 +70,9 @@ shared_examples_for '412 response' do delete request, params: params, headers: { 'HTTP_IF_UNMODIFIED_SINCE' => Time.now } end - it 'returns accepted' do + it 'returns 204 with an empty body' do expect(response).to have_gitlab_http_status(success_status) + expect(response.body).to eq('') if success_status == 204 end end end diff --git a/spec/support/shared_examples/requests/self_monitoring_shared_examples.rb b/spec/support/shared_examples/requests/self_monitoring_shared_examples.rb new file mode 100644 index 0000000000000000000000000000000000000000..949aa079435d5741c1004160cebf8566c3ba20c1 --- /dev/null +++ b/spec/support/shared_examples/requests/self_monitoring_shared_examples.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'not accessible if feature flag is disabled' do + before do + stub_feature_flags(self_monitoring_project: false) + end + + it 'returns not_implemented' do + subject + + aggregate_failures do + expect(response).to have_gitlab_http_status(:not_implemented) + expect(json_response).to eq( + 'message' => _('Self-monitoring is not enabled on this GitLab server, contact your administrator.'), + 'documentation_url' => help_page_path('administration/monitoring/gitlab_instance_administration_project/index') + ) + end + end +end + +RSpec.shared_examples 'not accessible to non-admin users' do + context 'with unauthenticated user' do + it 'redirects to signin page' do + subject + + expect(response).to redirect_to(new_user_session_path) + end + end + + context 'with authenticated non-admin user' do + before do + login_as(create(:user)) + end + + it 'returns status not_found' do + subject + + expect(response).to have_gitlab_http_status(:not_found) + end + end +end + +# Requires subject and worker_class and status_api to be defined +# let(:worker_class) { SelfMonitoringProjectCreateWorker } +# let(:status_api) { status_create_self_monitoring_project_admin_application_settings_path } +# subject { post create_self_monitoring_project_admin_application_settings_path } +RSpec.shared_examples 'triggers async worker, returns sidekiq job_id with response accepted' do + it 'returns sidekiq job_id of expected length' do + subject + + job_id = json_response['job_id'] + + aggregate_failures do + expect(job_id).to be_present + expect(job_id.length).to be <= Admin::ApplicationSettingsController::PARAM_JOB_ID_MAX_SIZE + end + end + + it 'triggers async worker' do + expect(worker_class).to receive(:perform_async) + + subject + end + + it 'returns accepted response' do + subject + + aggregate_failures do + expect(response).to have_gitlab_http_status(:accepted) + expect(json_response.keys).to contain_exactly('job_id', 'monitor_status') + expect(json_response).to include( + 'monitor_status' => status_api + ) + end + end + + it 'returns job_id' do + fake_job_id = 'b5b28910d97563e58c2fe55f' + allow(worker_class).to receive(:perform_async).and_return(fake_job_id) + + subject + + expect(json_response).to include('job_id' => fake_job_id) + end +end + +# Requires job_id and subject to be defined +# let(:job_id) { 'job_id' } +# subject do +# get status_create_self_monitoring_project_admin_application_settings_path, +# params: { job_id: job_id } +# end +RSpec.shared_examples 'handles invalid job_id' do + context 'with invalid job_id' do + let(:job_id) { 'a' * 51 } + + it 'returns bad_request if job_id too long' do + subject + + aggregate_failures do + expect(response).to have_gitlab_http_status(:bad_request) + expect(json_response).to eq('message' => 'Parameter "job_id" cannot ' \ + "exceed length of #{Admin::ApplicationSettingsController::PARAM_JOB_ID_MAX_SIZE}") + end + end + end +end + +# Requires in_progress_message and subject to be defined +# let(:in_progress_message) { 'Job to create self-monitoring project is in progress' } +# subject do +# get status_create_self_monitoring_project_admin_application_settings_path, +# params: { job_id: job_id } +# end +RSpec.shared_examples 'sets polling header and returns accepted' do + it 'sets polling header' do + expect(::Gitlab::PollingInterval).to receive(:set_header) + + subject + end + + it 'returns accepted' do + subject + + aggregate_failures do + expect(response).to have_gitlab_http_status(:accepted) + expect(json_response).to eq( + 'message' => in_progress_message + ) + end + end +end diff --git a/spec/support/shared_examples/services/boards/boards_list_service.rb b/spec/support/shared_examples/services/boards/boards_list_service.rb index 25dc2e04942ca29ac73e2e289ca0dce3f374a21e..18d45ee324ad6b9f4517ff8ed8a8e70b72141baf 100644 --- a/spec/support/shared_examples/services/boards/boards_list_service.rb +++ b/spec/support/shared_examples/services/boards/boards_list_service.rb @@ -29,3 +29,20 @@ shared_examples 'boards list service' do expect(service.execute).to eq [board] end end + +shared_examples 'multiple boards list service' do + let(:service) { described_class.new(parent, double) } + let!(:board_B) { create(:board, resource_parent: parent, name: 'B-board') } + let!(:board_c) { create(:board, resource_parent: parent, name: 'c-board') } + let!(:board_a) { create(:board, resource_parent: parent, name: 'a-board') } + + describe '#execute' do + it 'returns all issue boards' do + expect(service.execute.size).to eq(3) + end + + it 'returns boards ordered by name' do + expect(service.execute).to eq [board_a, board_B, board_c] + end + end +end diff --git a/spec/support/shared_examples/services/metrics/dashboard_shared_examples.rb b/spec/support/shared_examples/services/metrics/dashboard_shared_examples.rb new file mode 100644 index 0000000000000000000000000000000000000000..30d91346df3edebf349cce08d93abf517a2cc9f0 --- /dev/null +++ b/spec/support/shared_examples/services/metrics/dashboard_shared_examples.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +shared_examples_for 'misconfigured dashboard service response' do |status_code, message = nil| + it 'returns an appropriate message and status code', :aggregate_failures do + result = service_call + + expect(result.keys).to contain_exactly(:message, :http_status, :status) + expect(result[:status]).to eq(:error) + expect(result[:http_status]).to eq(status_code) + expect(result[:message]).to eq(message) if message + end +end + +shared_examples_for 'valid dashboard service response for schema' do + it 'returns a json representation of the dashboard' do + result = service_call + + expect(result.keys).to contain_exactly(:dashboard, :status) + expect(result[:status]).to eq(:success) + + expect(JSON::Validator.fully_validate(dashboard_schema, result[:dashboard])).to be_empty + end +end + +shared_examples_for 'valid dashboard service response' do + let(:dashboard_schema) { JSON.parse(fixture_file('lib/gitlab/metrics/dashboard/schemas/dashboard.json')) } + + it_behaves_like 'valid dashboard service response for schema' +end + +shared_examples_for 'caches the unprocessed dashboard for subsequent calls' do + it do + expect(YAML).to receive(:safe_load).once.and_call_original + + described_class.new(*service_params).get_dashboard + described_class.new(*service_params).get_dashboard + end +end + +shared_examples_for 'valid embedded dashboard service response' do + let(:dashboard_schema) { JSON.parse(fixture_file('lib/gitlab/metrics/dashboard/schemas/embedded_dashboard.json')) } + + it_behaves_like 'valid dashboard service response for schema' +end + +shared_examples_for 'raises error for users with insufficient permissions' do + context 'when the user does not have sufficient access' do + let(:user) { build(:user) } + + it_behaves_like 'misconfigured dashboard service response', :unauthorized + end +end diff --git a/spec/support/shared_examples/unique_ip_check_shared_examples.rb b/spec/support/shared_examples/unique_ip_check_shared_examples.rb index 65d86ddee9ebe71f018f7c5365b262ffb410dbe9..9bdfa762fc8f730e6a018acf51c8c07c1691379f 100644 --- a/spec/support/shared_examples/unique_ip_check_shared_examples.rb +++ b/spec/support/shared_examples/unique_ip_check_shared_examples.rb @@ -2,6 +2,8 @@ shared_context 'unique ips sign in limit' do include StubENV + let(:request_context) { Gitlab::RequestContext.instance } + before do Gitlab::Redis::Cache.with(&:flushall) Gitlab::Redis::Queues.with(&:flushall) @@ -15,10 +17,13 @@ shared_context 'unique ips sign in limit' do unique_ips_limit_enabled: true, unique_ips_limit_time_window: 10000 ) + + # Make sure we're working with the same reqeust context everywhere + allow(Gitlab::RequestContext).to receive(:instance).and_return(request_context) end def change_ip(ip) - allow(Gitlab::RequestContext).to receive(:client_ip).and_return(ip) + allow(request_context).to receive(:client_ip).and_return(ip) end def request_from_ip(ip) diff --git a/spec/support/shared_examples/uploaders/upload_type_shared_examples.rb b/spec/support/shared_examples/uploaders/upload_type_shared_examples.rb new file mode 100644 index 0000000000000000000000000000000000000000..91d2526cde28bece292cba0d12f59b6f26678c8d --- /dev/null +++ b/spec/support/shared_examples/uploaders/upload_type_shared_examples.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +def check_content_matches_extension!(file = double(read: nil, path: '')) + magic_file = UploadTypeCheck::MagicFile.new(file) + uploader.check_content_matches_extension!(magic_file) +end + +shared_examples 'upload passes content type check' do + it 'does not raise error' do + expect { check_content_matches_extension! }.not_to raise_error + end +end + +shared_examples 'upload fails content type check' do + it 'raises error' do + expect { check_content_matches_extension! }.to raise_error(CarrierWave::IntegrityError) + end +end + +def upload_type_checked_filenames(filenames) + Array(filenames).each do |filename| + # Feed the uploader "some" content. + path = File.join('spec', 'fixtures', 'dk.png') + file = File.new(path, 'r') + # Rename the file with what we want. + allow(file).to receive(:path).and_return(filename) + + # Force the content type to match the extension type. + mime_type = MimeMagic.by_path(filename) + allow(MimeMagic).to receive(:by_magic).and_return(mime_type) + + uploaded_file = Rack::Test::UploadedFile.new(file, original_filename: filename) + uploader.cache!(uploaded_file) + end +end + +def upload_type_checked_fixtures(upload_fixtures) + upload_fixtures = Array(upload_fixtures) + upload_fixtures.each do |upload_fixture| + path = File.join('spec', 'fixtures', upload_fixture) + uploader.cache!(fixture_file_upload(path)) + end +end + +shared_examples 'type checked uploads' do |upload_fixtures = nil, filenames: nil| + it 'check type' do + upload_fixtures = Array(upload_fixtures) + filenames = Array(filenames) + + times = upload_fixtures.length + filenames.length + expect(uploader).to receive(:check_content_matches_extension!).exactly(times).times + + upload_type_checked_fixtures(upload_fixtures) unless upload_fixtures.empty? + upload_type_checked_filenames(filenames) unless filenames.empty? + end +end + +shared_examples 'skipped type checked uploads' do |upload_fixtures = nil, filenames: nil| + it 'skip type check' do + expect(uploader).not_to receive(:check_content_matches_extension!) + + upload_type_checked_fixtures(upload_fixtures) if upload_fixtures + upload_type_checked_filenames(filenames) if filenames + end +end diff --git a/spec/support/shared_examples/workers/concerns/reenqueuer_shared_examples.rb b/spec/support/shared_examples/workers/concerns/reenqueuer_shared_examples.rb new file mode 100644 index 0000000000000000000000000000000000000000..7dffbb04fdc44f9b1482cfa0a51190e1dbbbda81 --- /dev/null +++ b/spec/support/shared_examples/workers/concerns/reenqueuer_shared_examples.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +# Expects `worker_class` to be defined +shared_examples_for 'reenqueuer' do + subject(:job) { worker_class.new } + + before do + allow(job).to receive(:sleep) # faster tests + end + + it 'implements lease_timeout' do + expect(job.lease_timeout).to be_a(ActiveSupport::Duration) + end + + describe '#perform' do + it 'tries to obtain a lease' do + expect_to_obtain_exclusive_lease(job.lease_key) + + job.perform + end + end +end + +# Example usage: +# +# it_behaves_like 'it is rate limited to 1 call per', 5.seconds do +# subject { described_class.new } +# let(:rate_limited_method) { subject.perform } +# end +# +shared_examples_for 'it is rate limited to 1 call per' do |minimum_duration| + before do + # Allow Timecop freeze and travel without the block form + Timecop.safe_mode = false + Timecop.freeze + + time_travel_during_rate_limited_method(actual_duration) + end + + after do + Timecop.return + Timecop.safe_mode = true + end + + context 'when the work finishes in 0 seconds' do + let(:actual_duration) { 0 } + + it 'sleeps exactly the minimum duration' do + expect(subject).to receive(:sleep).with(a_value_within(0.01).of(minimum_duration)) + + rate_limited_method + end + end + + context 'when the work finishes in 10% of minimum duration' do + let(:actual_duration) { 0.1 * minimum_duration } + + it 'sleeps 90% of minimum duration' do + expect(subject).to receive(:sleep).with(a_value_within(0.01).of(0.9 * minimum_duration)) + + rate_limited_method + end + end + + context 'when the work finishes in 90% of minimum duration' do + let(:actual_duration) { 0.9 * minimum_duration } + + it 'sleeps 10% of minimum duration' do + expect(subject).to receive(:sleep).with(a_value_within(0.01).of(0.1 * minimum_duration)) + + rate_limited_method + end + end + + context 'when the work finishes exactly at minimum duration' do + let(:actual_duration) { minimum_duration } + + it 'does not sleep' do + expect(subject).not_to receive(:sleep) + + rate_limited_method + end + end + + context 'when the work takes 10% longer than minimum duration' do + let(:actual_duration) { 1.1 * minimum_duration } + + it 'does not sleep' do + expect(subject).not_to receive(:sleep) + + rate_limited_method + end + end + + context 'when the work takes twice as long as minimum duration' do + let(:actual_duration) { 2 * minimum_duration } + + it 'does not sleep' do + expect(subject).not_to receive(:sleep) + + rate_limited_method + end + end + + def time_travel_during_rate_limited_method(actual_duration) + # Save the original implementation of ensure_minimum_duration + original_ensure_minimum_duration = subject.method(:ensure_minimum_duration) + + allow(subject).to receive(:ensure_minimum_duration) do |minimum_duration, &block| + original_ensure_minimum_duration.call(minimum_duration) do + # Time travel inside the block inside ensure_minimum_duration + Timecop.travel(actual_duration) if actual_duration && actual_duration > 0 + end + end + end +end diff --git a/spec/support/shared_examples/workers/self_monitoring_shared_examples.rb b/spec/support/shared_examples/workers/self_monitoring_shared_examples.rb new file mode 100644 index 0000000000000000000000000000000000000000..89c0841fbd6a3ccd11685105e7fc20386493b74b --- /dev/null +++ b/spec/support/shared_examples/workers/self_monitoring_shared_examples.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +# This shared_example requires the following variables: +# let(:service_class) { Gitlab::DatabaseImporters::SelfMonitoring::Project::DeleteService } +# let(:service) { instance_double(service_class) } +RSpec.shared_examples 'executes service' do + before do + allow(service_class).to receive(:new) { service } + end + + it 'runs the service' do + expect(service).to receive(:execute) + + subject.perform + end +end + +RSpec.shared_examples 'returns in_progress based on Sidekiq::Status' do + it 'returns true when job is enqueued' do + jid = described_class.perform_async + + expect(described_class.in_progress?(jid)).to eq(true) + end + + it 'returns false when job does not exist' do + expect(described_class.in_progress?('fake_jid')).to eq(false) + end +end diff --git a/spec/tasks/gitlab/generate_sample_prometheus_data_spec.rb b/spec/tasks/gitlab/generate_sample_prometheus_data_spec.rb index 72e61f5c524f7c5a7b73c3c0d451d1159de44e4b..7620047624affa0a0cfea4ec812798f44966c150 100644 --- a/spec/tasks/gitlab/generate_sample_prometheus_data_spec.rb +++ b/spec/tasks/gitlab/generate_sample_prometheus_data_spec.rb @@ -17,7 +17,7 @@ describe 'gitlab:generate_sample_prometheus_data rake task' do it 'creates the file correctly' do Rake.application.rake_require 'tasks/gitlab/generate_sample_prometheus_data' allow(Environment).to receive(:find).and_return(environment) - allow(environment).to receive_message_chain(:prometheus_adapter, :prometheus_client, :query_range) { sample_query_result } + allow(environment).to receive_message_chain(:prometheus_adapter, :prometheus_client, :query_range) { sample_query_result[30] } run_rake_task('gitlab:generate_sample_prometheus_data', [environment.id]) expect(File.exist?(sample_query_file)).to be true diff --git a/spec/tasks/gitlab/import_export/import_rake_spec.rb b/spec/tasks/gitlab/import_export/import_rake_spec.rb index 18b89912b9f56dc99fb23c1b6ddb3c2598dcd54a..b824ede03b2a0d94ef71d82cc9f08d65001a9a8b 100644 --- a/spec/tasks/gitlab/import_export/import_rake_spec.rb +++ b/spec/tasks/gitlab/import_export/import_rake_spec.rb @@ -76,37 +76,13 @@ describe 'gitlab:import_export:import rake task', :sidekiq do let(:not_imported_message) { /Total number of not imported relations: 1/ } let(:error) { /Validation failed: Notes is invalid/ } - context 'when import_graceful_failures feature flag is enabled' do - before do - stub_feature_flags(import_graceful_failures: true) - end - - it 'performs project import successfully' do - expect { subject }.to output(not_imported_message).to_stdout - expect { subject }.not_to raise_error - - expect(project.merge_requests).to be_empty - expect(project.import_state.last_error).to be_nil - expect(project.import_state.status).to eq('finished') - end - end - - context 'when import_graceful_failures feature flag is disabled' do - before do - stub_feature_flags(import_graceful_failures: false) - end - - it 'fails project import with an error' do - # Catch exit call, and raise exception instead - expect_any_instance_of(GitlabProjectImport).to receive(:exit) - .with(1).and_raise(SystemExit) - - expect { subject }.to raise_error(SystemExit).and output(error).to_stdout + it 'performs project import successfully' do + expect { subject }.to output(not_imported_message).to_stdout + expect { subject }.not_to raise_error - expect(project.merge_requests).to be_empty - expect(project.import_state.last_error).to match(error) - expect(project.import_state.status).to eq('failed') - end + expect(project.merge_requests).to be_empty + expect(project.import_state.last_error).to be_nil + expect(project.import_state.status).to eq('finished') end end end diff --git a/spec/uploaders/avatar_uploader_spec.rb b/spec/uploaders/avatar_uploader_spec.rb index c0844360589c7fac80c06c0b1ce8fa083bca7242..669f75b2ee807a532d3001eaa0e3f5fcb91c4392 100644 --- a/spec/uploaders/avatar_uploader_spec.rb +++ b/spec/uploaders/avatar_uploader_spec.rb @@ -46,4 +46,16 @@ describe AvatarUploader do expect(uploader.absolute_path).to eq(absolute_path) end end + + context 'upload type check' do + AvatarUploader::SAFE_IMAGE_EXT.each do |ext| + context "#{ext} extension" do + it_behaves_like 'type checked uploads', filenames: "image.#{ext}" + end + end + + context 'skip image/svg+xml integrity check' do + it_behaves_like 'skipped type checked uploads', filenames: 'image.svg' + end + end end diff --git a/spec/uploaders/favicon_uploader_spec.rb b/spec/uploaders/favicon_uploader_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..4d6c849883a1375ff83f08e3048f4aa0267f693f --- /dev/null +++ b/spec/uploaders/favicon_uploader_spec.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe FaviconUploader do + let_it_be(:model) { build_stubbed(:user) } + let_it_be(:uploader) { described_class.new(model, :favicon) } + + context 'upload type check' do + FaviconUploader::EXTENSION_WHITELIST.each do |ext| + context "#{ext} extension" do + it_behaves_like 'type checked uploads', filenames: "image.#{ext}" + end + end + end + + context 'upload non-whitelisted file extensions' do + it 'will deny upload' do + path = File.join('spec', 'fixtures', 'banana_sample.gif') + fixture_file = fixture_file_upload(path) + expect { uploader.cache!(fixture_file) }.to raise_exception(CarrierWave::IntegrityError) + end + end +end diff --git a/spec/uploaders/upload_type_check_spec.rb b/spec/uploaders/upload_type_check_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..a4895f6a956ec6b16bb52c0ca58f7113a4b993d7 --- /dev/null +++ b/spec/uploaders/upload_type_check_spec.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe UploadTypeCheck do + include_context 'uploader with type check' + + def upload_fixture(filename) + fixture_file_upload(File.join('spec', 'fixtures', filename)) + end + + describe '#check_content_matches_extension! callback using file upload' do + context 'when extension matches contents' do + it 'not raise error on upload' do + expect { uploader.cache!(upload_fixture('banana_sample.gif')) }.not_to raise_error + end + end + + context 'when extension does not match contents' do + it 'raise error' do + expect { uploader.cache!(upload_fixture('not_a_png.png')) }.to raise_error(CarrierWave::IntegrityError) + end + end + end + + describe '#check_content_matches_extension! callback using stubs' do + include_context 'stubbed MimeMagic mime type detection' + + context 'when no extension and with ambiguous/text content' do + let(:magic_mime) { '' } + let(:ext_mime) { '' } + + it_behaves_like 'upload passes content type check' + end + + context 'when no extension and with non-text content' do + let(:magic_mime) { 'image/gif' } + let(:ext_mime) { '' } + + it_behaves_like 'upload fails content type check' + end + + # Most text files will exhibit this behaviour. + context 'when ambiguous content with text extension' do + let(:magic_mime) { '' } + let(:ext_mime) { 'text/plain' } + + it_behaves_like 'upload passes content type check' + end + + context 'when text content with text extension' do + let(:magic_mime) { 'text/plain' } + let(:ext_mime) { 'text/plain' } + + it_behaves_like 'upload passes content type check' + end + + context 'when ambiguous content with non-text extension' do + let(:magic_mime) { '' } + let(:ext_mime) { 'application/zip' } + + it_behaves_like 'upload fails content type check' + end + + # These are the types when uploading a .dmg + context 'when content and extension do not match' do + let(:magic_mime) { 'application/x-bzip' } + let(:ext_mime) { 'application/x-apple-diskimage' } + + it_behaves_like 'upload fails content type check' + end + end + + describe '#check_content_matches_extension! mime_type filtering' do + context 'without mime types' do + let(:mime_types) { nil } + + it_behaves_like 'type checked uploads', %w[doc_sample.txt rails_sample.jpg] + end + + context 'with mime types string' do + let(:mime_types) { 'text/plain' } + + it_behaves_like 'type checked uploads', %w[doc_sample.txt] + it_behaves_like 'skipped type checked uploads', %w[dk.png] + end + + context 'with mime types regex' do + let(:mime_types) { [/image\/(gif|png)/] } + + it_behaves_like 'type checked uploads', %w[banana_sample.gif dk.png] + it_behaves_like 'skipped type checked uploads', %w[doc_sample.txt] + end + + context 'with mime types array' do + let(:mime_types) { ['text/plain', /image\/png/] } + + it_behaves_like 'type checked uploads', %w[doc_sample.txt dk.png] + it_behaves_like 'skipped type checked uploads', %w[audio_sample.wav] + end + end + + describe '#check_content_matches_extension! extensions filtering' do + context 'without extensions' do + let(:extensions) { nil } + + it_behaves_like 'type checked uploads', %w[doc_sample.txt dk.png] + end + + context 'with extensions string' do + let(:extensions) { 'txt' } + + it_behaves_like 'type checked uploads', %w[doc_sample.txt] + it_behaves_like 'skipped type checked uploads', %w[rails_sample.jpg] + end + + context 'with extensions array of strings' do + let(:extensions) { %w[txt png] } + + it_behaves_like 'type checked uploads', %w[doc_sample.txt dk.png] + it_behaves_like 'skipped type checked uploads', %w[audio_sample.wav] + end + end +end diff --git a/spec/validators/qualified_domain_array_validator_spec.rb b/spec/validators/qualified_domain_array_validator_spec.rb index ab6cca4b671cf4a1857efadf544db16f2b49b1ca..664048c754450c1b8cbdb22c99af632f31317456 100644 --- a/spec/validators/qualified_domain_array_validator_spec.rb +++ b/spec/validators/qualified_domain_array_validator_spec.rb @@ -3,18 +3,19 @@ require 'spec_helper' describe QualifiedDomainArrayValidator do - class QualifiedDomainArrayValidatorTestClass - include ActiveModel::Validations + let(:qualified_domain_array_validator_test_class) do + Class.new do + include ActiveModel::Validations - attr_accessor :domain_array + attr_accessor :domain_array - def initialize(domain_array) - self.domain_array = domain_array + def initialize(domain_array) + self.domain_array = domain_array + end end end - let!(:record) do - QualifiedDomainArrayValidatorTestClass.new(['gitlab.com']) + qualified_domain_array_validator_test_class.new(['gitlab.com']) end subject { validator.validate(record) } diff --git a/spec/views/profiles/preferences/show.html.haml_spec.rb b/spec/views/profiles/preferences/show.html.haml_spec.rb index 52933c42621e20229470857f76a1819c656cb460..e3eb822b045f48bf8e68ec1036bf1e94d3ba6aaf 100644 --- a/spec/views/profiles/preferences/show.html.haml_spec.rb +++ b/spec/views/profiles/preferences/show.html.haml_spec.rb @@ -12,6 +12,16 @@ describe 'profiles/preferences/show' do allow(controller).to receive(:current_user).and_return(user) end + context 'behavior' do + before do + render + end + + it 'has option for Render whitespace characters in the Web IDE' do + expect(rendered).to have_unchecked_field('Render whitespace characters in the Web IDE') + end + end + context 'sourcegraph' do def have_sourcegraph_field(*args) have_field('user_sourcegraph_enabled', *args) diff --git a/spec/views/projects/ci/lints/show.html.haml_spec.rb b/spec/views/projects/ci/lints/show.html.haml_spec.rb index ea67478ff985ccc25f6cf6918f020579b5cbe3fd..8c3cf04bae64b3489e8e2ba711fbe148a3709411 100644 --- a/spec/views/projects/ci/lints/show.html.haml_spec.rb +++ b/spec/views/projects/ci/lints/show.html.haml_spec.rb @@ -75,6 +75,7 @@ describe 'projects/ci/lints/show' do it 'shows the correct values' do render + expect(rendered).to have_content('Status: syntax is correct') expect(rendered).to have_content('Tag list: dotnet') expect(rendered).to have_content('Only policy: refs, test@dude/repo') expect(rendered).to have_content('Except policy: refs, deploy') @@ -87,14 +88,14 @@ describe 'projects/ci/lints/show' do before do assign(:project, project) assign(:status, false) - assign(:error, 'Undefined error') + assign(:errors, ['Undefined error']) end it 'shows error message' do render expect(rendered).to have_content('Status: syntax is incorrect') - expect(rendered).to have_content('Error: Undefined error') + expect(rendered).to have_content('Undefined error') expect(rendered).not_to have_content('Tag list:') end end diff --git a/spec/views/projects/commit/branches.html.haml_spec.rb b/spec/views/projects/commit/branches.html.haml_spec.rb index 36da489a84f4c4c0366391bdda368c6f7dca0411..0fe7165a7903db6c74c90abcdc872549d5ae9099 100644 --- a/spec/views/projects/commit/branches.html.haml_spec.rb +++ b/spec/views/projects/commit/branches.html.haml_spec.rb @@ -11,7 +11,7 @@ describe 'projects/commit/branches.html.haml' do context 'when branches and tags are available' do before do - assign(:branches, ['master', 'test-branch']) + assign(:branches, %w[master test-branch]) assign(:branches_limit_exceeded, false) assign(:tags, ['tag1']) assign(:tags_limit_exceeded, false) @@ -35,7 +35,7 @@ describe 'projects/commit/branches.html.haml' do context 'when branches are available but no tags' do before do - assign(:branches, ['master', 'test-branch']) + assign(:branches, %w[master test-branch]) assign(:branches_limit_exceeded, false) assign(:tags, []) assign(:tags_limit_exceeded, true) diff --git a/spec/views/projects/diffs/_viewer.html.haml_spec.rb b/spec/views/projects/diffs/_viewer.html.haml_spec.rb index 1d5d6e1e78d4c5325e4949a89091381c065faa95..27f271bb1788d101b33b48de944487e6e28922b4 100644 --- a/spec/views/projects/diffs/_viewer.html.haml_spec.rb +++ b/spec/views/projects/diffs/_viewer.html.haml_spec.rb @@ -9,15 +9,7 @@ describe 'projects/diffs/_viewer.html.haml' do let(:commit) { project.commit('570e7b2abdd848b95f2f578043fc23bd6f6fd24d') } let(:diff_file) { commit.diffs.diff_file_with_new_path('files/ruby/popen.rb') } - let(:viewer_class) do - Class.new(DiffViewer::Base) do - include DiffViewer::Rich - - self.partial_name = 'text' - end - end - - let(:viewer) { viewer_class.new(diff_file) } + let(:viewer) { diff_file.simple_viewer } before do assign(:project, project) @@ -53,7 +45,7 @@ describe 'projects/diffs/_viewer.html.haml' do it 'renders the collapsed view' do render_view - expect(view).to render_template('projects/diffs/_collapsed') + expect(view).to render_template('projects/diffs/viewers/_collapsed') end end diff --git a/spec/views/projects/edit.html.haml_spec.rb b/spec/views/projects/edit.html.haml_spec.rb index 8005b5498384a43b2f919069ea1f2cabf3f5738d..e95dec56a2da2777e133933d475850c9508e7558 100644 --- a/spec/views/projects/edit.html.haml_spec.rb +++ b/spec/views/projects/edit.html.haml_spec.rb @@ -28,6 +28,33 @@ describe 'projects/edit' do end end + context 'merge suggestions settings' do + it 'displays all possible variables' do + render + + expect(rendered).to have_content('%{project_path}') + expect(rendered).to have_content('%{project_name}') + expect(rendered).to have_content('%{file_path}') + expect(rendered).to have_content('%{branch_name}') + expect(rendered).to have_content('%{username}') + expect(rendered).to have_content('%{user_full_name}') + end + + it 'displays a placeholder if none is set' do + render + + expect(rendered).to have_field('project[suggestion_commit_message]', placeholder: 'Apply suggestion to %{file_path}') + end + + it 'displays the user entered value' do + project.update!(suggestion_commit_message: 'refactor: changed %{file_path}') + + render + + expect(rendered).to have_field('project[suggestion_commit_message]', with: 'refactor: changed %{file_path}') + end + end + context 'forking' do before do assign(:project, project) diff --git a/spec/views/projects/issues/show.html.haml_spec.rb b/spec/views/projects/issues/show.html.haml_spec.rb index d34b17354457932674526dba42ae2befe8d5bb40..add4b44e9b6a957c0b9d3fa2e2f3a2f03b0be344 100644 --- a/spec/views/projects/issues/show.html.haml_spec.rb +++ b/spec/views/projects/issues/show.html.haml_spec.rb @@ -130,4 +130,26 @@ describe 'projects/issues/show' do expect(rendered).to have_selector('.status-box-open:not(.hidden)', text: 'Open') end end + + context 'when the issue is related to a sentry error' do + it 'renders a stack trace' do + sentry_issue = double(:sentry_issue, sentry_issue_identifier: '1066622') + allow(issue).to receive(:sentry_issue).and_return(sentry_issue) + render + + expect(rendered).to have_selector( + "#js-sentry-error-stack-trace"\ + "[data-issue-stack-trace-path="\ + "\"/#{project.full_path}/-/error_tracking/1066622/stack_trace.json\"]" + ) + end + end + + context 'when the issue is not related to a sentry error' do + it 'does not render a stack trace' do + render + + expect(rendered).not_to have_selector('#js-sentry-error-stack-trace') + end + end end diff --git a/spec/views/search/_results.html.haml_spec.rb b/spec/views/search/_results.html.haml_spec.rb index 628d2e10f93a2d9bfdf32f6170c929d98b320408..3243758c65082898c0e881909fc4c26b36d7befd 100644 --- a/spec/views/search/_results.html.haml_spec.rb +++ b/spec/views/search/_results.html.haml_spec.rb @@ -6,7 +6,7 @@ describe 'search/_results' do before do controller.params[:action] = 'show' - 3.times { create(:issue) } + create_list(:issue, 3) @search_objects = Issue.page(1).per(2) @scope = 'issues' diff --git a/spec/workers/chat_notification_worker_spec.rb b/spec/workers/chat_notification_worker_spec.rb index 91695674f5d0d8980ae8137e97de6322dd9ef7a2..e4dccf2bf6bd0416ab7cc370fd9c4ccc528c6a3c 100644 --- a/spec/workers/chat_notification_worker_spec.rb +++ b/spec/workers/chat_notification_worker_spec.rb @@ -8,6 +8,10 @@ describe ChatNotificationWorker do create(:ci_build, pipeline: create(:ci_pipeline, source: :chat)) end + it 'instructs sidekiq not to retry on failure' do + expect(described_class.get_sidekiq_options['retry']).to eq(false) + end + describe '#perform' do it 'does nothing when the build no longer exists' do expect(worker).not_to receive(:send_response) @@ -23,16 +27,31 @@ describe ChatNotificationWorker do worker.perform(chat_build.id) end - it 'reschedules the job if the trace sections could not be found' do - expect(worker) - .to receive(:send_response) - .and_raise(Gitlab::Chat::Output::MissingBuildSectionError) + context 'when the trace sections could not be found' do + it 'reschedules the job' do + expect(worker) + .to receive(:send_response) + .and_raise(Gitlab::Chat::Output::MissingBuildSectionError) - expect(described_class) - .to receive(:perform_in) - .with(described_class::RESCHEDULE_INTERVAL, chat_build.id) + expect(described_class) + .to receive(:perform_in) + .with(described_class::RESCHEDULE_INTERVAL, chat_build.id, 1) - worker.perform(chat_build.id) + worker.perform(chat_build.id) + end + + it "raises an error after #{described_class::RESCHEDULE_TIMEOUT} seconds of retrying" do + allow(described_class).to receive(:new).and_return(worker) + allow(worker).to receive(:send_response).and_raise(Gitlab::Chat::Output::MissingBuildSectionError) + + worker.perform(chat_build.id) + + expect { described_class.drain }.to raise_error(described_class::TimeoutExceeded) + + max_reschedules = described_class::RESCHEDULE_TIMEOUT / described_class::RESCHEDULE_INTERVAL + + expect(worker).to have_received(:send_response).exactly(max_reschedules + 1).times + end end end diff --git a/spec/workers/ci/archive_traces_cron_worker_spec.rb b/spec/workers/ci/archive_traces_cron_worker_spec.rb index fc700c15b109d4aa6fbd074588e57983870a97ed..789e83783bb8418f9181ffc0ade328ff8c13e0e0 100644 --- a/spec/workers/ci/archive_traces_cron_worker_spec.rb +++ b/spec/workers/ci/archive_traces_cron_worker_spec.rb @@ -35,8 +35,9 @@ describe Ci::ArchiveTracesCronWorker do it_behaves_like 'archives trace' it 'executes service' do - expect_any_instance_of(Ci::ArchiveTraceService) - .to receive(:execute).with(build, anything) + expect_next_instance_of(Ci::ArchiveTraceService) do |instance| + expect(instance).to receive(:execute).with(build, anything) + end subject end @@ -64,7 +65,9 @@ describe Ci::ArchiveTracesCronWorker do before do allow(Gitlab::ErrorTracking).to receive(:track_and_raise_for_dev_exception) - allow_any_instance_of(Gitlab::Ci::Trace).to receive(:archive!).and_raise('Unexpected error') + allow_next_instance_of(Gitlab::Ci::Trace) do |instance| + allow(instance).to receive(:archive!).and_raise('Unexpected error') + end end it 'puts a log' do diff --git a/spec/workers/ci/resource_groups/assign_resource_from_resource_group_worker_spec.rb b/spec/workers/ci/resource_groups/assign_resource_from_resource_group_worker_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..634d932121eca03aa200d8b6947baa2174c28533 --- /dev/null +++ b/spec/workers/ci/resource_groups/assign_resource_from_resource_group_worker_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Ci::ResourceGroups::AssignResourceFromResourceGroupWorker do + let(:worker) { described_class.new } + + describe '#perform' do + subject { worker.perform(resource_group_id) } + + context 'when resource group exists' do + let(:resource_group) { create(:ci_resource_group) } + let(:resource_group_id) { resource_group.id } + + it 'executes AssignResourceFromResourceGroupService' do + expect_next_instance_of(Ci::ResourceGroups::AssignResourceFromResourceGroupService, resource_group.project, nil) do |service| + expect(service).to receive(:execute).with(resource_group) + end + + subject + end + end + + context 'when build does not exist' do + let(:resource_group_id) { 123 } + + it 'does not execute AssignResourceFromResourceGroupService' do + expect(Ci::ResourceGroups::AssignResourceFromResourceGroupService).not_to receive(:new) + + subject + end + end + end +end diff --git a/spec/workers/concerns/gitlab/github_import/rescheduling_methods_spec.rb b/spec/workers/concerns/gitlab/github_import/rescheduling_methods_spec.rb index 294eacf09ab4ce423958cb4faaf912b5e5b6da4c..c4f6ddf9aca8c5a9f662015fc5e30cbc6a5b4874 100644 --- a/spec/workers/concerns/gitlab/github_import/rescheduling_methods_spec.rb +++ b/spec/workers/concerns/gitlab/github_import/rescheduling_methods_spec.rb @@ -57,9 +57,9 @@ describe Gitlab::GithubImport::ReschedulingMethods do expect(worker) .not_to receive(:notify_waiter) - expect_any_instance_of(Gitlab::GithubImport::Client) - .to receive(:rate_limit_resets_in) - .and_return(14) + expect_next_instance_of(Gitlab::GithubImport::Client) do |instance| + expect(instance).to receive(:rate_limit_resets_in).and_return(14) + end expect(worker.class) .to receive(:perform_in) diff --git a/spec/workers/concerns/reenqueuer_spec.rb b/spec/workers/concerns/reenqueuer_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..b28f83d211b28d9cf17700f98930abaa64d62e01 --- /dev/null +++ b/spec/workers/concerns/reenqueuer_spec.rb @@ -0,0 +1,179 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Reenqueuer do + include ExclusiveLeaseHelpers + + let_it_be(:worker_class) do + Class.new do + def self.name + 'Gitlab::Foo::Bar::DummyWorker' + end + + include ApplicationWorker + prepend Reenqueuer + + attr_reader :performed_args + + def perform(*args) + @performed_args = args + + success? # for stubbing + end + + def success? + false + end + + def lease_timeout + 30.seconds + end + end + end + + subject(:job) { worker_class.new } + + before do + allow(job).to receive(:sleep) # faster tests + end + + it_behaves_like 'reenqueuer' + + it_behaves_like 'it is rate limited to 1 call per', 5.seconds do + let(:rate_limited_method) { subject.perform } + end + + it 'disables Sidekiq retries' do + expect(job.sidekiq_options_hash).to include('retry' => false) + end + + describe '#perform', :clean_gitlab_redis_shared_state do + let(:arbitrary_args) { [:foo, 'bar', { a: 1 }] } + + context 'when the lease is available' do + it 'does perform' do + job.perform(*arbitrary_args) + + expect(job.performed_args).to eq(arbitrary_args) + end + end + + context 'when the lease is taken' do + before do + stub_exclusive_lease_taken(job.lease_key) + end + + it 'does not perform' do + job.perform(*arbitrary_args) + + expect(job.performed_args).to be_nil + end + end + + context 'when #perform returns truthy' do + before do + allow(job).to receive(:success?).and_return(true) + end + + it 'reenqueues the worker' do + expect(worker_class).to receive(:perform_async) + + job.perform + end + end + + context 'when #perform returns falsey' do + it 'does not reenqueue the worker' do + expect(worker_class).not_to receive(:perform_async) + + job.perform + end + end + end +end + +describe Reenqueuer::ReenqueuerSleeper do + let_it_be(:dummy_class) do + Class.new do + include Reenqueuer::ReenqueuerSleeper + + def rate_limited_method + ensure_minimum_duration(11.seconds) do + # do work + end + end + end + end + + subject(:dummy) { dummy_class.new } + + # Test that rate_limited_method is rate limited by ensure_minimum_duration + it_behaves_like 'it is rate limited to 1 call per', 11.seconds do + let(:rate_limited_method) { dummy.rate_limited_method } + end + + # Test ensure_minimum_duration more directly + describe '#ensure_minimum_duration' do + around do |example| + # Allow Timecop.travel without the block form + Timecop.safe_mode = false + + Timecop.freeze do + example.run + end + + Timecop.safe_mode = true + end + + let(:minimum_duration) { 4.seconds } + + context 'when the block completes well before the minimum duration' do + let(:time_left) { 3.seconds } + + it 'sleeps until the minimum duration' do + expect(dummy).to receive(:sleep).with(a_value_within(0.01).of(time_left)) + + dummy.ensure_minimum_duration(minimum_duration) do + Timecop.travel(minimum_duration - time_left) + end + end + end + + context 'when the block completes just before the minimum duration' do + let(:time_left) { 0.1.seconds } + + it 'sleeps until the minimum duration' do + expect(dummy).to receive(:sleep).with(a_value_within(0.01).of(time_left)) + + dummy.ensure_minimum_duration(minimum_duration) do + Timecop.travel(minimum_duration - time_left) + end + end + end + + context 'when the block completes just after the minimum duration' do + let(:time_over) { 0.1.seconds } + + it 'does not sleep' do + expect(dummy).not_to receive(:sleep) + + dummy.ensure_minimum_duration(minimum_duration) do + Timecop.travel(minimum_duration + time_over) + end + end + end + + context 'when the block completes well after the minimum duration' do + let(:time_over) { 10.seconds } + + it 'does not sleep' do + expect(dummy).not_to receive(:sleep) + + dummy.ensure_minimum_duration(minimum_duration) do + Timecop.travel(minimum_duration + time_over) + end + end + end + end +end diff --git a/spec/workers/container_expiration_policy_worker_spec.rb b/spec/workers/container_expiration_policy_worker_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..48ab16146337520122b6764aa617fa0339d88d8c --- /dev/null +++ b/spec/workers/container_expiration_policy_worker_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe ContainerExpirationPolicyWorker do + include ExclusiveLeaseHelpers + + subject { described_class.new.perform } + + context 'With no container expiration policies' do + it 'Does not execute any policies' do + expect(ContainerExpirationPolicyService).not_to receive(:new) + + subject + end + end + + context 'With container expiration policies' do + context 'a valid policy' do + let!(:container_expiration_policy) { create(:container_expiration_policy, :runnable) } + let(:user) { container_expiration_policy.project.owner } + + it 'runs the policy' do + service = instance_double(ContainerExpirationPolicyService, execute: true) + + expect(ContainerExpirationPolicyService) + .to receive(:new).with(container_expiration_policy.project, user).and_return(service) + + subject + end + end + + context 'a disabled policy' do + let!(:container_expiration_policy) { create(:container_expiration_policy, :runnable, :disabled) } + let(:user) {container_expiration_policy.project.owner } + + it 'does not run the policy' do + expect(ContainerExpirationPolicyService) + .not_to receive(:new).with(container_expiration_policy, user) + + subject + end + end + + context 'a policy that is not due for a run' do + let!(:container_expiration_policy) { create(:container_expiration_policy) } + let(:user) {container_expiration_policy.project.owner } + + it 'does not run the policy' do + expect(ContainerExpirationPolicyService) + .not_to receive(:new).with(container_expiration_policy, user) + + subject + end + end + end +end diff --git a/spec/workers/delete_merged_branches_worker_spec.rb b/spec/workers/delete_merged_branches_worker_spec.rb index 8c983859e3638f6430d8daf5c808be4a990c2370..3eaeb7e0797ab5a4400e7cb2cbfe96de7cb6f1f1 100644 --- a/spec/workers/delete_merged_branches_worker_spec.rb +++ b/spec/workers/delete_merged_branches_worker_spec.rb @@ -9,7 +9,9 @@ describe DeleteMergedBranchesWorker do describe "#perform" do it "delegates to Branches::DeleteMergedService" do - expect_any_instance_of(::Branches::DeleteMergedService).to receive(:execute).and_return(true) + expect_next_instance_of(::Branches::DeleteMergedService) do |instance| + expect(instance).to receive(:execute).and_return(true) + end worker.perform(project.id, project.owner.id) end diff --git a/spec/workers/deployments/finished_worker_spec.rb b/spec/workers/deployments/finished_worker_spec.rb index df62821e2cd3669c4f3ad64e5938199b4bb89946..2961ff599c37f24a32b7c9d86b5368a867f43b25 100644 --- a/spec/workers/deployments/finished_worker_spec.rb +++ b/spec/workers/deployments/finished_worker_spec.rb @@ -10,6 +10,20 @@ describe Deployments::FinishedWorker do allow(ProjectServiceWorker).to receive(:perform_async) end + it 'links merge requests to the deployment' do + deployment = create(:deployment) + service = instance_double(Deployments::LinkMergeRequestsService) + + expect(Deployments::LinkMergeRequestsService) + .to receive(:new) + .with(deployment) + .and_return(service) + + expect(service).to receive(:execute) + + worker.perform(deployment.id) + end + it 'executes project services for deployment_hooks' do deployment = create(:deployment) project = deployment.project diff --git a/spec/workers/expire_build_artifacts_worker_spec.rb b/spec/workers/expire_build_artifacts_worker_spec.rb index 0a0aea838d2c66d33a54ab8a7eb14e30cd5b5ff4..06561e94fb786f48c20f2e30e2f6d5c8144cf6cc 100644 --- a/spec/workers/expire_build_artifacts_worker_spec.rb +++ b/spec/workers/expire_build_artifacts_worker_spec.rb @@ -7,7 +7,9 @@ describe ExpireBuildArtifactsWorker do describe '#perform' do it 'executes a service' do - expect_any_instance_of(Ci::DestroyExpiredJobArtifactsService).to receive(:execute) + expect_next_instance_of(Ci::DestroyExpiredJobArtifactsService) do |instance| + expect(instance).to receive(:execute) + end worker.perform end diff --git a/spec/workers/file_hook_worker_spec.rb b/spec/workers/file_hook_worker_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..1a7e753fc4a71db327c55d3c6d83cd3e0e7e2a92 --- /dev/null +++ b/spec/workers/file_hook_worker_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe FileHookWorker do + include RepoHelpers + + let(:filename) { 'my_file_hook.rb' } + let(:data) { { 'event_name' => 'project_create' } } + + subject { described_class.new } + + describe '#perform' do + it 'executes Gitlab::FileHook with expected values' do + allow(Gitlab::FileHook).to receive(:execute).with(filename, data).and_return([true, '']) + + expect(subject.perform(filename, data)).to be_truthy + end + + it 'logs message in case of file_hook execution failure' do + allow(Gitlab::FileHook).to receive(:execute).with(filename, data).and_return([false, 'permission denied']) + + expect(Gitlab::FileHookLogger).to receive(:error) + expect(subject.perform(filename, data)).to be_truthy + end + end +end diff --git a/spec/workers/git_garbage_collect_worker_spec.rb b/spec/workers/git_garbage_collect_worker_spec.rb index cc1c23bb9e71c0ef39a884d7d73970c016441b04..64ad4ba7eb62509df5755e49a92c5bbf7645785d 100644 --- a/spec/workers/git_garbage_collect_worker_spec.rb +++ b/spec/workers/git_garbage_collect_worker_spec.rb @@ -230,8 +230,8 @@ describe GitGarbageCollectWorker do new_commit_sha = Rugged::Commit.create( rugged, message: "hello world #{SecureRandom.hex(6)}", - author: Gitlab::Git.committer_hash(email: 'foo@bar', name: 'baz'), - committer: Gitlab::Git.committer_hash(email: 'foo@bar', name: 'baz'), + author: { email: 'foo@bar', name: 'baz' }, + committer: { email: 'foo@bar', name: 'baz' }, tree: old_commit.tree, parents: [old_commit] ) diff --git a/spec/workers/gitlab/github_import/stage/import_repository_worker_spec.rb b/spec/workers/gitlab/github_import/stage/import_repository_worker_spec.rb index 6d47d73b92eba1d606f674d7306ada5410902e60..3a8fe73622a72c13e57f979316ad7ae60f86f968 100644 --- a/spec/workers/gitlab/github_import/stage/import_repository_worker_spec.rb +++ b/spec/workers/gitlab/github_import/stage/import_repository_worker_spec.rb @@ -21,9 +21,9 @@ describe Gitlab::GithubImport::Stage::ImportRepositoryWorker do it 'schedules the importing of the base data' do client = double(:client) - expect_any_instance_of(Gitlab::GithubImport::Importer::RepositoryImporter) - .to receive(:execute) - .and_return(true) + expect_next_instance_of(Gitlab::GithubImport::Importer::RepositoryImporter) do |instance| + expect(instance).to receive(:execute).and_return(true) + end expect(Gitlab::GithubImport::Stage::ImportBaseDataWorker) .to receive(:perform_async) @@ -37,9 +37,9 @@ describe Gitlab::GithubImport::Stage::ImportRepositoryWorker do it 'does not schedule the importing of the base data' do client = double(:client) - expect_any_instance_of(Gitlab::GithubImport::Importer::RepositoryImporter) - .to receive(:execute) - .and_return(false) + expect_next_instance_of(Gitlab::GithubImport::Importer::RepositoryImporter) do |instance| + expect(instance).to receive(:execute).and_return(false) + end expect(Gitlab::GithubImport::Stage::ImportBaseDataWorker) .not_to receive(:perform_async) diff --git a/spec/workers/gitlab_shell_worker_spec.rb b/spec/workers/gitlab_shell_worker_spec.rb index 0758cfc4ee2a5ce89efecf1bfe02d47be3663607..5dedf5be9fa11aecd4f4a1ea6ddcfccaa1abb559 100644 --- a/spec/workers/gitlab_shell_worker_spec.rb +++ b/spec/workers/gitlab_shell_worker_spec.rb @@ -7,7 +7,9 @@ describe GitlabShellWorker do describe '#perform with add_key' do it 'calls add_key on Gitlab::Shell' do - expect_any_instance_of(Gitlab::Shell).to receive(:add_key).with('foo', 'bar') + expect_next_instance_of(Gitlab::Shell) do |instance| + expect(instance).to receive(:add_key).with('foo', 'bar') + end worker.perform(:add_key, 'foo', 'bar') end end diff --git a/spec/workers/gitlab_usage_ping_worker_spec.rb b/spec/workers/gitlab_usage_ping_worker_spec.rb index aff5d112cdd4ef4c3edb7c1c2c56cf6dc96d05a8..198daf404931ceee70ee7190f6b4536e603d2cb7 100644 --- a/spec/workers/gitlab_usage_ping_worker_spec.rb +++ b/spec/workers/gitlab_usage_ping_worker_spec.rb @@ -8,7 +8,9 @@ describe GitlabUsagePingWorker do it 'delegates to SubmitUsagePingService' do allow(subject).to receive(:try_obtain_lease).and_return(true) - expect_any_instance_of(SubmitUsagePingService).to receive(:execute) + expect_next_instance_of(SubmitUsagePingService) do |instance| + expect(instance).to receive(:execute) + end subject.perform end diff --git a/spec/workers/hashed_storage/migrator_worker_spec.rb b/spec/workers/hashed_storage/migrator_worker_spec.rb index 9180da870582280dfd8dd6741677f03d37834d55..ac76a306f432cf39e931910a6653d67e68d96216 100644 --- a/spec/workers/hashed_storage/migrator_worker_spec.rb +++ b/spec/workers/hashed_storage/migrator_worker_spec.rb @@ -10,7 +10,9 @@ describe HashedStorage::MigratorWorker do describe '#perform' do it 'delegates to MigratorService' do - expect_any_instance_of(Gitlab::HashedStorage::Migrator).to receive(:bulk_migrate).with(start: 5, finish: 10) + expect_next_instance_of(Gitlab::HashedStorage::Migrator) do |instance| + expect(instance).to receive(:bulk_migrate).with(start: 5, finish: 10) + end worker.perform(5, 10) end diff --git a/spec/workers/hashed_storage/rollbacker_worker_spec.rb b/spec/workers/hashed_storage/rollbacker_worker_spec.rb index 3ca2601df0fa936c5b6f280a214fb7a66ea6818c..55fc4fb0fe10002d2959f3fd81afa43b41bf3d5b 100644 --- a/spec/workers/hashed_storage/rollbacker_worker_spec.rb +++ b/spec/workers/hashed_storage/rollbacker_worker_spec.rb @@ -10,7 +10,9 @@ describe HashedStorage::RollbackerWorker do describe '#perform' do it 'delegates to MigratorService' do - expect_any_instance_of(Gitlab::HashedStorage::Migrator).to receive(:bulk_rollback).with(start: 5, finish: 10) + expect_next_instance_of(Gitlab::HashedStorage::Migrator) do |instance| + expect(instance).to receive(:bulk_rollback).with(start: 5, finish: 10) + end worker.perform(5, 10) end diff --git a/spec/workers/import_issues_csv_worker_spec.rb b/spec/workers/import_issues_csv_worker_spec.rb index 89370c4890d631fdee9e81b264cdf5dda099b33b..03944cfb05d982c6bca7c77b6ce0432280c37751 100644 --- a/spec/workers/import_issues_csv_worker_spec.rb +++ b/spec/workers/import_issues_csv_worker_spec.rb @@ -11,7 +11,9 @@ describe ImportIssuesCsvWorker do describe '#perform' do it 'calls #execute on Issues::ImportCsvService and destroys upload' do - expect_any_instance_of(Issues::ImportCsvService).to receive(:execute).and_return({ success: 5, errors: [], valid_file: true }) + expect_next_instance_of(Issues::ImportCsvService) do |instance| + expect(instance).to receive(:execute).and_return({ success: 5, errors: [], valid_file: true }) + end worker.perform(user.id, project.id, upload.id) diff --git a/spec/workers/new_release_worker_spec.rb b/spec/workers/new_release_worker_spec.rb index 9010c36f795cd303aabcc24c29ecf26cc2d39732..9d8c5bbf9194c8f99e0180c74e11dd66ca02d139 100644 --- a/spec/workers/new_release_worker_spec.rb +++ b/spec/workers/new_release_worker_spec.rb @@ -6,7 +6,9 @@ describe NewReleaseWorker do let(:release) { create(:release) } it 'sends a new release notification' do - expect_any_instance_of(NotificationService).to receive(:send_new_release_notifications).with(release) + expect_next_instance_of(NotificationService) do |instance| + expect(instance).to receive(:send_new_release_notifications).with(release) + end described_class.new.perform(release.id) end diff --git a/spec/workers/pipeline_update_worker_spec.rb b/spec/workers/pipeline_update_worker_spec.rb index 0225e4a9601ddd77ac2b68b5f7be72068e3e4b85..187298034cc6e2f9124153739ae4e1ecf0a2080e 100644 --- a/spec/workers/pipeline_update_worker_spec.rb +++ b/spec/workers/pipeline_update_worker_spec.rb @@ -8,7 +8,7 @@ describe PipelineUpdateWorker do let(:pipeline) { create(:ci_pipeline) } it 'updates pipeline status' do - expect_any_instance_of(Ci::Pipeline).to receive(:update_status) + expect_any_instance_of(Ci::Pipeline).to receive(:set_status).with('skipped') described_class.new.perform(pipeline.id) end diff --git a/spec/workers/plugin_worker_spec.rb b/spec/workers/plugin_worker_spec.rb deleted file mode 100644 index ca6c99861317b32df95d71b57a8603171b18618c..0000000000000000000000000000000000000000 --- a/spec/workers/plugin_worker_spec.rb +++ /dev/null @@ -1,27 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -describe PluginWorker do - include RepoHelpers - - let(:filename) { 'my_plugin.rb' } - let(:data) { { 'event_name' => 'project_create' } } - - subject { described_class.new } - - describe '#perform' do - it 'executes Gitlab::Plugin with expected values' do - allow(Gitlab::Plugin).to receive(:execute).with(filename, data).and_return([true, '']) - - expect(subject.perform(filename, data)).to be_truthy - end - - it 'logs message in case of plugin execution failure' do - allow(Gitlab::Plugin).to receive(:execute).with(filename, data).and_return([false, 'permission denied']) - - expect(Gitlab::PluginLogger).to receive(:error) - expect(subject.perform(filename, data)).to be_truthy - end - end -end diff --git a/spec/workers/repository_import_worker_spec.rb b/spec/workers/repository_import_worker_spec.rb index b8767af8eee086d2eb729f717d99039a710f3ad5..507098582c921ff2f11eae0e682187ed40ad138f 100644 --- a/spec/workers/repository_import_worker_spec.rb +++ b/spec/workers/repository_import_worker_spec.rb @@ -21,8 +21,9 @@ describe RepositoryImportWorker do allow(subject).to receive(:jid).and_return(jid) - expect_any_instance_of(Projects::ImportService).to receive(:execute) - .and_return({ status: :ok }) + expect_next_instance_of(Projects::ImportService) do |instance| + expect(instance).to receive(:execute).and_return({ status: :ok }) + end # Works around https://github.com/rspec/rspec-mocks/issues/910 expect(Project).to receive(:find).with(started_project.id).and_return(started_project) @@ -36,8 +37,9 @@ describe RepositoryImportWorker do context 'when the import was successful' do it 'imports a project' do - expect_any_instance_of(Projects::ImportService).to receive(:execute) - .and_return({ status: :ok }) + expect_next_instance_of(Projects::ImportService) do |instance| + expect(instance).to receive(:execute).and_return({ status: :ok }) + end # Works around https://github.com/rspec/rspec-mocks/issues/910 expect(Project).to receive(:find).with(project.id).and_return(project) @@ -54,7 +56,9 @@ describe RepositoryImportWorker do error = %q{remote: Not Found fatal: repository 'https://user:pass@test.com/root/repoC.git/' not found } import_state.update(jid: '123') - expect_any_instance_of(Projects::ImportService).to receive(:execute).and_return({ status: :error, message: error }) + expect_next_instance_of(Projects::ImportService) do |instance| + expect(instance).to receive(:execute).and_return({ status: :error, message: error }) + end expect do subject.perform(project.id) @@ -67,7 +71,9 @@ describe RepositoryImportWorker do 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_next_instance_of(Projects::ImportService) do |instance| + expect(instance).to receive(:execute).and_return({ status: :error, message: error }) + end expect do subject.perform(project.id) @@ -93,8 +99,9 @@ describe RepositoryImportWorker do .to receive(:async?) .and_return(true) - expect_any_instance_of(ProjectImportState) - .not_to receive(:finish) + expect_next_instance_of(ProjectImportState) do |instance| + expect(instance).not_to receive(:finish) + end subject.perform(project.id) end diff --git a/spec/workers/self_monitoring_project_create_worker_spec.rb b/spec/workers/self_monitoring_project_create_worker_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..00c288bdc46d1f00c2727c8cc08ca3582240f2cc --- /dev/null +++ b/spec/workers/self_monitoring_project_create_worker_spec.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe SelfMonitoringProjectCreateWorker do + describe '#perform' do + let(:service_class) { Gitlab::DatabaseImporters::SelfMonitoring::Project::CreateService } + let(:service) { instance_double(service_class) } + + it_behaves_like 'executes service' + end + + describe '.in_progress?', :clean_gitlab_redis_shared_state do + it_behaves_like 'returns in_progress based on Sidekiq::Status' + end +end diff --git a/spec/workers/self_monitoring_project_delete_worker_spec.rb b/spec/workers/self_monitoring_project_delete_worker_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..3685c73513ecc348f84ace681592d97a06b6d9ad --- /dev/null +++ b/spec/workers/self_monitoring_project_delete_worker_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe SelfMonitoringProjectDeleteWorker do + let_it_be(:jid) { 'b5b28910d97563e58c2fe55f' } + let_it_be(:data_key) { "self_monitoring_delete_result:#{jid}" } + + describe '#perform' do + let(:service_class) { Gitlab::DatabaseImporters::SelfMonitoring::Project::DeleteService } + let(:service) { instance_double(service_class) } + + it_behaves_like 'executes service' + end + + describe '.status', :clean_gitlab_redis_shared_state do + it_behaves_like 'returns in_progress based on Sidekiq::Status' + end +end diff --git a/spec/workers/stage_update_worker_spec.rb b/spec/workers/stage_update_worker_spec.rb index 429d42bac29c18c18392b91ff069b079a184c911..8a57cc6bbffe48884496c54edc2159abfdb42d18 100644 --- a/spec/workers/stage_update_worker_spec.rb +++ b/spec/workers/stage_update_worker_spec.rb @@ -8,7 +8,7 @@ describe StageUpdateWorker do let(:stage) { create(:ci_stage_entity) } it 'updates stage status' do - expect_any_instance_of(Ci::Stage).to receive(:update_status) + expect_any_instance_of(Ci::Stage).to receive(:set_status).with('skipped') described_class.new.perform(stage.id) end diff --git a/vendor/elastic_stack/values.yaml b/vendor/elastic_stack/values.yaml index 9346c0e25e6c9a7029223d8bbf0f903089ba8788..ccbff1ab38dfc74a996ebc05dfc061a0de6231af 100644 --- a/vendor/elastic_stack/values.yaml +++ b/vendor/elastic_stack/values.yaml @@ -11,14 +11,7 @@ elasticsearch: replicas: 1 kibana: - enabled: true - env: - ELASTICSEARCH_HOSTS: http://elastic-stack-elasticsearch-client:9200 - ingress: - enabled: true - annotations: - kubernetes.io/ingress.class: "nginx" - kubernetes.io/tls-acme: "true" + enabled: false logstash: enabled: false @@ -41,7 +34,35 @@ nginx-ldapauth-proxy: enabled: false elasticsearch-curator: - enabled: false + enabled: true + configMaps: + config_yml: |- + --- + client: + hosts: + - elastic-stack-elasticsearch-client + port: 9200 + action_file_yml: |- + --- + actions: + 1: + action: delete_indices + description: >- + Delete indices older than 15 days (based on index name), for filebeat- + prefixed indices. Ignore the error if the filter does not result in an + actionable list of indices (ignore_empty_list) and exit cleanly. + options: + ignore_empty_list: True + filters: + - filtertype: pattern + kind: prefix + value: filebeat- + - filtertype: age + source: name + direction: older + timestring: '%Y.%m.%d' + unit: days + unit_count: 15 elasticsearch-exporter: enabled: false diff --git a/vendor/gitignore/C++.gitignore b/vendor/gitignore/C++.gitignore old mode 100755 new mode 100644 diff --git a/vendor/gitignore/Java.gitignore b/vendor/gitignore/Java.gitignore old mode 100755 new mode 100644 diff --git a/vendor/prometheus/values.yaml b/vendor/prometheus/values.yaml index be79a0c53800ec68531577cadb3ca914c186a304..25384b088a2cb26dd282e742709b8e55c60579b7 100644 --- a/vendor/prometheus/values.yaml +++ b/vendor/prometheus/values.yaml @@ -1,7 +1,5 @@ alertmanager: enabled: false - image: - tag: v0.15.2 kubeStateMetrics: enabled: true @@ -14,8 +12,6 @@ pushgateway: server: fullnameOverride: "prometheus-prometheus-server" - image: - tag: v2.4.3 serverFiles: alerts: {} diff --git a/yarn.lock b/yarn.lock index bb62ffa1bb346c0600be2fed5bad25ac24d9e812..a4c4890fc4fe32d194d772e2fe8c4a16e3921007 100644 --- a/yarn.lock +++ b/yarn.lock @@ -732,15 +732,15 @@ dependencies: vue-eslint-parser "^6.0.4" -"@gitlab/svgs@^1.88.0": - version "1.88.0" - resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-1.88.0.tgz#0a9b72e9591264fcac592ebf9944665c70f48de2" - integrity sha512-ZgepCvZoB/lFdgttHtu8+9YlRZlVc9MnHDbbqcQCFBvrfOjY1wq12ikxnNbwKj8QNA47TRJvSS0TkHgMWYnbsA== +"@gitlab/svgs@^1.89.0": + version "1.89.0" + resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-1.89.0.tgz#5bdaff1b0af1cc07ed34e89c21c34c7c6a3e1caa" + integrity sha512-vI6VobZs6mq2Bbiej5bYMHyvtn8kD1O/uHSlyY9jgJoa2TXU+jFI9DqUpJmx8EIHt+o0qm/8G3XsFGEr5gLb7Q== -"@gitlab/ui@8.8.0": - version "8.8.0" - resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-8.8.0.tgz#c22b4dece89d224c525b3510970f3c61321a6765" - integrity sha512-fjAGSgfau28iq+Uhivc5OPwu3ZLUL25gFuW1rKeQFgnkVEaQ9IRvdM8RD9+kgXWUsccsrafQkz/nOUmp85o8yQ== +"@gitlab/ui@8.17.0": + version "8.17.0" + resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-8.17.0.tgz#674baa9b5c05fa6ecb23b233c5b308ff82ba5660" + integrity sha512-klWzMFU3IdoLUgRP6OTYUyO+EDfckG9/cphPKVBaf0MLx4HpjiW5LwGW3stL3A9SlyauCwAZOLkqbJKbN5pxCQ== dependencies: "@babel/standalone" "^7.0.0" "@gitlab/vue-toasted" "^1.3.0" @@ -750,6 +750,7 @@ highlight.js "^9.13.1" js-beautify "^1.8.8" lodash "^4.17.14" + portal-vue "^2.1.6" resize-observer-polyfill "^1.5.1" url-search-params-polyfill "^5.0.0" vue "^2.6.10" @@ -985,10 +986,10 @@ "@sentry/types" "5.10.0" tslib "^1.9.3" -"@sourcegraph/code-host-integration@^0.0.14": - version "0.0.14" - resolved "https://registry.yarnpkg.com/@sourcegraph/code-host-integration/-/code-host-integration-0.0.14.tgz#e12b08371dc37bf4a468450b008c6e167705e1a8" - integrity sha512-S4+K+3RKFd49Btl1D9LOdWXROgXevUwOBwp+vDUuGgzT2d6Y+qjalUJ0t8CjbYzdBdJun+2/Zi1+SXfm+S+xVg== +"@sourcegraph/code-host-integration@^0.0.18": + version "0.0.18" + resolved "https://registry.yarnpkg.com/@sourcegraph/code-host-integration/-/code-host-integration-0.0.18.tgz#814467cdbc94bbfee5768193acf89fdf404ca949" + integrity sha512-PNKR6QI2MK17YJ4BBmWBz7SVRPIJZKbGkQpdB9jHsvQhdSxspdpWFaMu+HKeg96zpStdLhFOcDPn1wlKbdGy+w== "@types/anymatch@*": version "1.3.0" @@ -1067,7 +1068,7 @@ resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.3.tgz#3dca0e3f33b200fc7d1139c0cd96c1268cadfd9d" integrity sha512-tHq6qdbT9U1IRSGf14CL0pUlULksvY9OZ+5eEgl1N7t+OA3tGvNpxJCzuKQlsNgCVwbAs670L1vcVQi8j9HjnA== -"@types/node@*", "@types/node@>=6", "@types/node@^10.11.7": +"@types/node@*", "@types/node@>=6": version "10.12.9" resolved "https://registry.yarnpkg.com/@types/node/-/node-10.12.9.tgz#a07bfa74331471e1dc22a47eb72026843f7b95c8" integrity sha512-eajkMXG812/w3w4a1OcBlaTwsFPO5F7fJ/amy+tieQxEMWBlbV1JGSjkFM+zkHNf81Cad+dfIRA+IBkvmvdAeA== @@ -1077,11 +1078,6 @@ resolved "https://registry.yarnpkg.com/@types/parse5/-/parse5-5.0.0.tgz#9ae2106efc443d7c1e26570aa8247828c9c80f11" integrity sha512-J5D3z703XTDIGQFYXsnU9uRCW9e9mMEFO0Kpe6kykyiboqziru/RlZ0hM2P+PKTG4NHG1SjLrqae/NrV2iJApQ== -"@types/semver@^5.5.0": - version "5.5.0" - resolved "https://registry.yarnpkg.com/@types/semver/-/semver-5.5.0.tgz#146c2a29ee7d3bae4bf2fcb274636e264c813c45" - integrity sha512-41qEJgBH/TWgo5NFSvBCJ1qkoi3Q6ONSF2avrHq1LVEZfYpdHmj0y9SuTK+u9ZhG1sYQKBL1AWXKyLWP4RaUoQ== - "@types/stack-utils@^1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-1.0.1.tgz#0a851d3bd96498fa25c33ab7278ed3bd65f06c3e" @@ -1172,12 +1168,14 @@ source-map "~0.6.1" vue-template-es2015-compiler "^1.9.0" -"@vue/test-utils@^1.0.0-beta.25": - version "1.0.0-beta.25" - resolved "https://registry.yarnpkg.com/@vue/test-utils/-/test-utils-1.0.0-beta.25.tgz#4703076de3076bac42cdd242cd53e6fb8752ed8c" - integrity sha512-mfvguEmEpAn0BuT4u+qm+0J1NTKgQS+ffUyWHY1QeSovIkJcy98fj1rO+PJgiZSEvGjjnDNX+qmofYFPLrofbA== +"@vue/test-utils@^1.0.0-beta.30": + version "1.0.0-beta.30" + resolved "https://registry.yarnpkg.com/@vue/test-utils/-/test-utils-1.0.0-beta.30.tgz#d5f26d1e2411fdb7fa7fdedb61b4b4ea4194c49d" + integrity sha512-Wyvcha9fNk8+kzTDwb3xWGjPkCPzHSYSwKP6MplrPTG/auhqoad7JqUEceZLc6u7AU4km2pPQ8/m9s0RgCZ0NA== dependencies: - lodash "^4.17.4" + dom-event-types "^1.0.0" + lodash "^4.17.15" + pretty "^2.0.0" "@webassemblyjs/ast@1.8.5": version "1.8.5" @@ -2840,6 +2838,15 @@ concat-stream@^1.5.0: readable-stream "^2.2.2" typedarray "^0.0.6" +condense-newlines@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/condense-newlines/-/condense-newlines-0.2.1.tgz#3de985553139475d32502c83b02f60684d24c55f" + integrity sha1-PemFVTE5R10yUCyDsC9gaE0kxV8= + dependencies: + extend-shallow "^2.0.1" + is-whitespace "^0.3.0" + kind-of "^3.0.2" + config-chain@^1.1.12: version "1.1.12" resolved "https://registry.yarnpkg.com/config-chain/-/config-chain-1.1.12.tgz#0fde8d091200eb5e808caf25fe618c02f48e4efa" @@ -3506,10 +3513,10 @@ d3@^4.13.0: d3-voronoi "1.1.2" d3-zoom "1.7.1" -d3@^5.12, d3@^5.7.0: - version "5.12.0" - resolved "https://registry.yarnpkg.com/d3/-/d3-5.12.0.tgz#0ddeac879c28c882317cd439b495290acd59ab61" - integrity sha512-flYVMoVuhPFHd9zVCe2BxIszUWqBcd5fvQGMNRmSiBrgdnh6Vlruh60RJQTouAK9xPbOB0plxMvBm4MoyODXNg== +d3@^5.14, d3@^5.7.0: + version "5.15.0" + resolved "https://registry.yarnpkg.com/d3/-/d3-5.15.0.tgz#ffd44958e6a3cb8a59a84429c45429b8bca5677a" + integrity sha512-C+E80SL2nLLtmykZ6klwYj5rPqB5nlfN5LdWEAVdWPppqTD8taoJi2PxLZjPeYT8FFRR2yucXq+kBlOnnvZeLg== dependencies: d3-array "1" d3-axis "1" @@ -3543,22 +3550,23 @@ d3@^5.12, d3@^5.7.0: d3-voronoi "1" d3-zoom "1" -dagre-d3@dagrejs/dagre-d3: - version "0.6.4-pre" - resolved "https://codeload.github.com/dagrejs/dagre-d3/tar.gz/e1a00e5cb518f5d2304a35647e024f31d178e55b" +dagre-d3@^0.6.4: + version "0.6.4" + resolved "https://registry.yarnpkg.com/dagre-d3/-/dagre-d3-0.6.4.tgz#0728d5ce7f177ca2337df141ceb60fbe6eeb7b29" + integrity sha512-e/6jXeCP7/ptlAM48clmX4xTZc5Ek6T6kagS7Oz2HrYSdqcLZFLqpAfh7ldbZRFfxCZVyh61NEPR08UQRVxJzQ== dependencies: - d3 "^5.12" - dagre "^0.8.4" - graphlib "^2.1.7" + d3 "^5.14" + dagre "^0.8.5" + graphlib "^2.1.8" lodash "^4.17.15" -dagre@^0.8.4: - version "0.8.4" - resolved "https://registry.yarnpkg.com/dagre/-/dagre-0.8.4.tgz#26b9fb8f7bdc60c6110a0458c375261836786061" - integrity sha512-Dj0csFDrWYKdavwROb9FccHfTC4fJbyF/oJdL9LNZJ8WUvl968P6PAKEriGqfbdArVJEmmfA+UyumgWEwcHU6A== +dagre@^0.8.4, dagre@^0.8.5: + version "0.8.5" + resolved "https://registry.yarnpkg.com/dagre/-/dagre-0.8.5.tgz#ba30b0055dac12b6c1fcc247817442777d06afee" + integrity sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw== dependencies: - graphlib "^2.1.7" - lodash "^4.17.4" + graphlib "^2.1.8" + lodash "^4.17.15" dashdash@^1.12.0: version "1.14.1" @@ -3637,10 +3645,10 @@ decamelize@^1.1.0, decamelize@^1.1.1, decamelize@^1.1.2, decamelize@^1.2.0: resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" integrity sha1-9lNNFRSCabIDUue+4m9QH5oZEpA= -deckar01-task_list@^2.2.1: - version "2.2.1" - resolved "https://registry.yarnpkg.com/deckar01-task_list/-/deckar01-task_list-2.2.1.tgz#e1e8a16c4fd6e153e51fd9258fdbee067ebcd86b" - integrity sha512-aNAVYAYwONXezSQy2p5M67wjZE+U7JpPotdegbyy1Wq35V6jDhF3qndJYA1rYnY3aI9ifCep6EMGPav/UQaBZw== +deckar01-task_list@^2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/deckar01-task_list/-/deckar01-task_list-2.3.1.tgz#f3ffd5319d7b9e27c596dc8d823b13f617ed7db7" + integrity sha512-046BmNx8e1Yia07SlSsyWb1h7wlGibcesRaw6Szw2qVppoe/EPtckFDRTY9P/laWiW3xjsfNLE1nOBOTAMKEWQ== decode-uri-component@^0.2.0: version "0.2.0" @@ -3873,6 +3881,11 @@ document-register-element@1.13.1: dependencies: lightercollective "^0.1.0" +dom-event-types@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/dom-event-types/-/dom-event-types-1.0.0.tgz#5830a0a29e1bf837fe50a70cd80a597232813cae" + integrity sha512-2G2Vwi2zXTHBGqXHsJ4+ak/iP0N8Ar+G8a7LiD2oup5o4sQWytwqqrZu/O6hIMV0KMID2PL69OhpshLO0n7UJQ== + dom-serialize@^2.2.0: version "2.2.1" resolved "https://registry.yarnpkg.com/dom-serialize/-/dom-serialize-2.2.1.tgz#562ae8999f44be5ea3076f5419dcd59eb43ac95b" @@ -3980,15 +3993,13 @@ editions@^1.3.3: resolved "https://registry.yarnpkg.com/editions/-/editions-1.3.4.tgz#3662cb592347c3168eb8e498a0ff73271d67f50b" integrity sha512-gzao+mxnYDzIysXKMQi/+M1mjy/rjestjg6OPoYTtI+3Izp23oiGZitsl9lPDPiTGXbcSIk1iJWhliSaglxnUg== -editorconfig@^0.15.2: - version "0.15.2" - resolved "https://registry.yarnpkg.com/editorconfig/-/editorconfig-0.15.2.tgz#047be983abb9ab3c2eefe5199cb2b7c5689f0702" - integrity sha512-GWjSI19PVJAM9IZRGOS+YKI8LN+/sjkSjNyvxL5ucqP9/IqtYNXBaQ/6c/hkPNYQHyOHra2KoXZI/JVpuqwmcQ== +editorconfig@^0.15.3: + version "0.15.3" + resolved "https://registry.yarnpkg.com/editorconfig/-/editorconfig-0.15.3.tgz#bef84c4e75fb8dcb0ce5cee8efd51c15999befc5" + integrity sha512-M9wIMFx96vq0R4F+gRpY3o2exzb8hEj/n9S8unZtHSvYjibBp/iMufSzvmOcV/laG0ZtuTVGtiJggPOSW2r93g== dependencies: - "@types/node" "^10.11.7" - "@types/semver" "^5.5.0" commander "^2.19.0" - lru-cache "^4.1.3" + lru-cache "^4.1.5" semver "^5.6.0" sigmund "^1.0.1" @@ -5312,12 +5323,12 @@ graceful-fs@^4.1.11, graceful-fs@^4.1.15, graceful-fs@^4.1.2, graceful-fs@^4.1.6 resolved "https://registry.yarnpkg.com/graceful-readlink/-/graceful-readlink-1.0.1.tgz#4cafad76bc62f02fa039b2f94e9a3dd3a391a725" integrity sha1-TK+tdrxi8C+gObL5Tpo906ORpyU= -graphlib@^2.1.7: - version "2.1.7" - resolved "https://registry.yarnpkg.com/graphlib/-/graphlib-2.1.7.tgz#b6a69f9f44bd9de3963ce6804a2fc9e73d86aecc" - integrity sha512-TyI9jIy2J4j0qgPmOOrHTCtpPqJGN/aurBwc6ZT+bRii+di1I+Wv3obRhVrmBEXet+qkMaEX67dXrwsd3QQM6w== +graphlib@^2.1.7, graphlib@^2.1.8: + version "2.1.8" + resolved "https://registry.yarnpkg.com/graphlib/-/graphlib-2.1.8.tgz#5761d414737870084c92ec7b5dbcb0592c9d35da" + integrity sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A== dependencies: - lodash "^4.17.5" + lodash "^4.17.15" graphql-tag@^2.10.0: version "2.10.0" @@ -5681,6 +5692,11 @@ immediate@~3.0.5: resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b" integrity sha1-nbHb0Pr43m++D13V5Wu2BigN5ps= +immer@^5.2.1: + version "5.2.1" + resolved "https://registry.yarnpkg.com/immer/-/immer-5.2.1.tgz#7d4f74c242178e87151d595f48db1b5c51580485" + integrity sha512-9U1GEbJuH6nVoyuFRgTQDGMzcBuNBPfXM3M7Pp/sdmYKTKYOBUZGgeUb9H57GfLK/xC1DMLarWX2FrhMBfUJ8g== + import-fresh@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-2.0.0.tgz#d81355c15612d386c61f9ddd3922d4304822a546" @@ -6109,7 +6125,7 @@ is-plain-obj@^1.1.0: resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-1.1.0.tgz#71a50c8429dfca773c92a390a4a03b39fcd51d3e" integrity sha1-caUMhCnfync8kqOQpKA7OfzVHT4= -is-plain-object@^2.0.1, is-plain-object@^2.0.3, is-plain-object@^2.0.4: +is-plain-object@^2.0.3, is-plain-object@^2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-2.0.4.tgz#2c163b3fafb1b606d9d17928f05c2a1c38e07677" integrity sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og== @@ -6180,6 +6196,11 @@ is-whitespace-character@^1.0.0: resolved "https://registry.yarnpkg.com/is-whitespace-character/-/is-whitespace-character-1.0.2.tgz#ede53b4c6f6fb3874533751ec9280d01928d03ed" integrity sha512-SzM+T5GKUCtLhlHFKt2SDAX2RFzfS6joT91F2/WSi9LxgFdsnhfPK/UIA+JhRR2xuyLdrCys2PiFDrtn1fU5hQ== +is-whitespace@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/is-whitespace/-/is-whitespace-0.3.0.tgz#1639ecb1be036aec69a54cbb401cfbed7114ab7f" + integrity sha1-Fjnssb4DauxppUy7QBz77XEUq38= + is-windows@^1.0.1, is-windows@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/is-windows/-/is-windows-1.0.2.tgz#d1850eb9791ecd18e6182ce12a30f396634bb19d" @@ -6746,15 +6767,15 @@ js-base64@^2.1.8: resolved "https://registry.yarnpkg.com/js-base64/-/js-base64-2.5.1.tgz#1efa39ef2c5f7980bb1784ade4a8af2de3291121" integrity sha512-M7kLczedRMYX4L8Mdh4MzyAMM9O5osx+4FcOQuTvr3A9F2D9S5JXheN0ewNbrvK2UatkTRhL5ejGmGSjNMiZuw== -js-beautify@^1.8.8: - version "1.8.9" - resolved "https://registry.yarnpkg.com/js-beautify/-/js-beautify-1.8.9.tgz#08e3c05ead3ecfbd4f512c3895b1cda76c87d523" - integrity sha512-MwPmLywK9RSX0SPsUJjN7i+RQY9w/yC17Lbrq9ViEefpLRgqAR2BgrMN2AbifkUuhDV8tRauLhLda/9+bE0YQA== +js-beautify@^1.6.12, js-beautify@^1.8.8: + version "1.10.2" + resolved "https://registry.yarnpkg.com/js-beautify/-/js-beautify-1.10.2.tgz#88c9099cd6559402b124cfab18754936f8a7b178" + integrity sha512-ZtBYyNUYJIsBWERnQP0rPN9KjkrDfJcMjuVGcvXOUJrD1zmOGwhRwQ4msG+HJ+Ni/FA7+sRQEMYVzdTQDvnzvQ== dependencies: config-chain "^1.1.12" - editorconfig "^0.15.2" + editorconfig "^0.15.3" glob "^7.1.3" - mkdirp "~0.5.0" + mkdirp "~0.5.1" nopt "~4.0.1" js-cookie@^2.1.3: @@ -7350,7 +7371,7 @@ lowlight@^1.11.0: fault "^1.0.2" highlight.js "~9.13.0" -lru-cache@4.1.x, lru-cache@^4.0.1, lru-cache@^4.1.2, lru-cache@^4.1.3: +lru-cache@4.1.x, lru-cache@^4.0.1, lru-cache@^4.1.2, lru-cache@^4.1.5: version "4.1.5" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-4.1.5.tgz#8bbe50ea85bed59bc9e33dcab8235ee9bcf443cd" integrity sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g== @@ -7614,22 +7635,21 @@ merge2@^1.2.3: resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.2.3.tgz#7ee99dbd69bb6481689253f018488a1b902b0ed5" integrity sha512-gdUU1Fwj5ep4kplwcmftruWofEFt6lfpkkr3h860CXbAB9c3hGb55EOL2ali0Td5oebvW0E1+3Sr+Ur7XfKpRA== -mermaid@^8.4.2: - version "8.4.2" - resolved "https://registry.yarnpkg.com/mermaid/-/mermaid-8.4.2.tgz#91d3d8e9541e72eed7a78d0e882db11564fab3bb" - integrity sha512-vYSCP2u4XkOnjliWz/QIYwvzF/znQAq22vWJJ3YV40SnwV2JQyHblnwwNYXCprkXw7XfwBKDpSNaJ3HP4WfnZw== +mermaid@^8.4.5: + version "8.4.5" + resolved "https://registry.yarnpkg.com/mermaid/-/mermaid-8.4.5.tgz#48d5722cbc72be2ad01002795835d7ca1b48e000" + integrity sha512-oJWgZBtT2rvAdmqHvKjDwb3tOut1+ksfgDdZrVhhNcdzNibzGPjCsmMPpVXjkFYzKZCVunIbAkfxltSuaGIhaw== dependencies: "@braintree/sanitize-url" "^3.1.0" crypto-random-string "^3.0.1" d3 "^5.7.0" dagre "^0.8.4" - dagre-d3 dagrejs/dagre-d3 + dagre-d3 "^0.6.4" graphlib "^2.1.7" he "^1.2.0" lodash "^4.17.11" minify "^4.1.1" moment-mini "^2.22.1" - prettier "^1.18.2" scope-css "^1.2.1" methods@~1.1.2: @@ -7796,7 +7816,7 @@ mixin-deep@^1.2.0: for-in "^1.0.2" is-extendable "^1.0.1" -mkdirp@0.5.x, mkdirp@0.x, "mkdirp@>=0.5 0", mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@~0.5.0, mkdirp@~0.5.1: +mkdirp@0.5.x, mkdirp@0.x, "mkdirp@>=0.5 0", mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@~0.5.1: version "0.5.1" resolved "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz#30057438eac6cf7f8c4767f38648d6697d75c903" integrity sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM= @@ -7820,10 +7840,10 @@ monaco-editor-webpack-plugin@^1.7.0: dependencies: "@types/webpack" "^4.4.19" -monaco-editor@^0.15.6: - version "0.15.6" - resolved "https://registry.yarnpkg.com/monaco-editor/-/monaco-editor-0.15.6.tgz#d63b3b06f86f803464f003b252627c3eb4a09483" - integrity sha512-JoU9V9k6KqT9R9Tiw1RTU8ohZ+Xnf9DMg6Ktqqw5hILumwmq7xqa/KLXw513uTUsWbhtnHoSJYYR++u3pkyxJg== +monaco-editor@0.18.1, monaco-editor@^0.18.1: + version "0.18.1" + resolved "https://registry.yarnpkg.com/monaco-editor/-/monaco-editor-0.18.1.tgz#ced7c305a23109875feeaf395a504b91f6358cfc" + integrity sha512-fmL+RFZ2Hrezy+X/5ZczQW51LUmvzfcqOurnkCIRFTyjdVjzR7JvENzI6+VKBJzJdPh6EYL4RoWl92b2Hrk9fw== mousetrap@^1.4.6: version "1.4.6" @@ -8774,10 +8794,10 @@ popper.js@^1.14.7, popper.js@^1.15.0: resolved "https://registry.yarnpkg.com/popper.js/-/popper.js-1.15.0.tgz#5560b99bbad7647e9faa475c6b8056621f5a4ff2" integrity sha512-w010cY1oCUmI+9KwwlWki+r5jxKfTFDVoadl7MSrIujHU5MJ5OR6HTDj6Xo8aoR/QsA56x8jKjA59qGH4ELtrA== -portal-vue@^2.1.5: - version "2.1.5" - resolved "https://registry.yarnpkg.com/portal-vue/-/portal-vue-2.1.5.tgz#ecd0997cb32958205151cb72f40fd4f38d175e5c" - integrity sha512-vZmdMn0mOo7puvxoMQ5zju6S29aFD+9yygJxyWQtPaMXS9xunAeoYdnx6yzfL9J8HD8pMZYgSieEIbioAKhrSQ== +portal-vue@^2.1.5, portal-vue@^2.1.6: + version "2.1.7" + resolved "https://registry.yarnpkg.com/portal-vue/-/portal-vue-2.1.7.tgz#ea08069b25b640ca08a5b86f67c612f15f4e4ad4" + integrity sha512-+yCno2oB3xA7irTt0EU5Ezw22L2J51uKAacE/6hMPMoO/mx3h4rXFkkBkT4GFsMDv/vEe8TNKC3ujJJ0PTwb6g== portfinder@^1.0.24: version "1.0.24" @@ -8970,7 +8990,7 @@ prettier@1.16.3: resolved "https://registry.yarnpkg.com/prettier/-/prettier-1.16.3.tgz#8c62168453badef702f34b45b6ee899574a6a65d" integrity sha512-kn/GU6SMRYPxUakNXhpP0EedT/KmaPzr0H5lIsDogrykbaxOpOfAFfk5XA7DZrJyMAv1wlMV3CPcZruGXVVUZw== -prettier@1.18.2, prettier@^1.18.2: +prettier@1.18.2: version "1.18.2" resolved "https://registry.yarnpkg.com/prettier/-/prettier-1.18.2.tgz#6823e7c5900017b4bd3acf46fe9ac4b4d7bda9ea" integrity sha512-OeHeMc0JhFE9idD4ZdtNibzY0+TPHSpSSb9h8FqtP+YnoZZ1sl8Vc9b1sasjfymH3SonAF4QcA2+mzHPhMvIiw== @@ -8985,6 +9005,15 @@ pretty-format@^24.8.0: ansi-styles "^3.2.0" react-is "^16.8.4" +pretty@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/pretty/-/pretty-2.0.0.tgz#adbc7960b7bbfe289a557dc5f737619a220d06a5" + integrity sha1-rbx5YLe7/iiaVX3F9zdhmiINBqU= + dependencies: + condense-newlines "^0.2.1" + extend-shallow "^2.0.1" + js-beautify "^1.6.12" + prismjs@^1.6.0: version "1.6.0" resolved "https://registry.yarnpkg.com/prismjs/-/prismjs-1.6.0.tgz#118d95fb7a66dba2272e343b345f5236659db365" @@ -10025,6 +10054,11 @@ serialize-javascript@^1.4.0, serialize-javascript@^1.7.0: resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-1.7.0.tgz#d6e0dfb2a3832a8c94468e6eb1db97e55a192a65" integrity sha512-ke8UG8ulpFOxO8f8gRYabHQe/ZntKlcig2Mp+8+URDP1D8vJZ0KUt7LYo07q25Z/+JVSgpr/cui9PIp5H6/+nA== +serialize-javascript@^2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-2.1.2.tgz#ecec53b0e0317bdc95ef76ab7074b7384785fa61" + integrity sha512-rs9OggEUF0V4jUSecXazOYsLfu7OGK2qIn3c7IPBiffz32XniEp/TX9Xmc9LQfK2nQ2QKHvZ2oygKUGU0lG4jQ== + serve-index@^1.9.1: version "1.9.1" resolved "https://registry.yarnpkg.com/serve-index/-/serve-index-1.9.1.tgz#d3768d69b1e7d82e5ce050fff5b453bea12a9239" @@ -10053,20 +10087,10 @@ set-blocking@^2.0.0, set-blocking@~2.0.0: resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" integrity sha1-BF+XgtARrppoA93TgrJDkrPYkPc= -set-value@^0.4.3: - version "0.4.3" - resolved "https://registry.yarnpkg.com/set-value/-/set-value-0.4.3.tgz#7db08f9d3d22dc7f78e53af3c3bf4666ecdfccf1" - integrity sha1-fbCPnT0i3H945Trzw79GZuzfzPE= - dependencies: - extend-shallow "^2.0.1" - is-extendable "^0.1.1" - is-plain-object "^2.0.1" - to-object-path "^0.3.0" - -set-value@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/set-value/-/set-value-2.0.0.tgz#71ae4a88f0feefbbf52d1ea604f3fb315ebb6274" - integrity sha512-hw0yxk9GT/Hr5yJEYnHNKYXkIA8mVJgd9ditYZCe16ZczcaELYYcfvaXesNACk2O8O0nTiPQcQhGUQj8JLzeeg== +set-value@^2.0.0, set-value@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/set-value/-/set-value-2.0.1.tgz#a18d40530e6f07de4228c7defe4227af8cad005b" + integrity sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw== dependencies: extend-shallow "^2.0.1" is-extendable "^0.1.1" @@ -10849,16 +10873,16 @@ term-size@^1.2.0: dependencies: execa "^0.7.0" -terser-webpack-plugin@^1.4.1: - version "1.4.1" - resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-1.4.1.tgz#61b18e40eaee5be97e771cdbb10ed1280888c2b4" - integrity sha512-ZXmmfiwtCLfz8WKZyYUuuHf3dMYEjg8NrjHMb0JqHVHVOSkzp3cW2/XG1fP3tRhqEqSzMwzzRQGtAPbs4Cncxg== +terser-webpack-plugin@^1.4.3: + version "1.4.3" + resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-1.4.3.tgz#5ecaf2dbdc5fb99745fd06791f46fc9ddb1c9a7c" + integrity sha512-QMxecFz/gHQwteWwSo5nTc6UaICqN1bMedC5sMtUc7y3Ha3Q8y6ZO0iCR8pq4RJC8Hjf0FEPEHZqcMB/+DFCrA== dependencies: cacache "^12.0.2" find-cache-dir "^2.1.0" is-wsl "^1.1.0" schema-utils "^1.0.0" - serialize-javascript "^1.7.0" + serialize-javascript "^2.1.2" source-map "^0.6.1" terser "^4.1.2" webpack-sources "^1.4.0" @@ -11326,14 +11350,14 @@ unified@^7.0.0: x-is-string "^0.1.0" union-value@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/union-value/-/union-value-1.0.0.tgz#5c71c34cb5bad5dcebe3ea0cd08207ba5aa1aea4" - integrity sha1-XHHDTLW61dzr4+oM0IIHulqhrqQ= + version "1.0.1" + resolved "https://registry.yarnpkg.com/union-value/-/union-value-1.0.1.tgz#0b6fe7b835aecda61c6ea4d4f02c14221e109847" + integrity sha512-tJfXmxMeWYnczCVs7XAEvIV7ieppALdyepWMkHkwciRpZraG/xwT+s2JN8+pr1+8jCRf80FFzvr+MpQeeoF4Xg== dependencies: arr-union "^3.1.0" get-value "^2.0.6" is-extendable "^0.1.1" - set-value "^0.4.3" + set-value "^2.0.1" uniq@^1.0.1: version "1.0.1" @@ -11710,10 +11734,10 @@ vue-template-es2015-compiler@^1.9.0: resolved "https://registry.yarnpkg.com/vue-template-es2015-compiler/-/vue-template-es2015-compiler-1.9.1.tgz#1ee3bc9a16ecbf5118be334bb15f9c46f82f5825" integrity sha512-4gDntzrifFnCEvyoO8PqyJDmguXgVPxKiIxrBKjIowvL9l+N66196+72XVYR8BBf1Uv1Fgt3bGevJ+sEmxfZzw== -vue-virtual-scroll-list@^1.3.1: - version "1.3.1" - resolved "https://registry.yarnpkg.com/vue-virtual-scroll-list/-/vue-virtual-scroll-list-1.3.1.tgz#efcb83d3a3dcc69cd886fa4de1130a65493e8f76" - integrity sha512-PMTxiK9/P1LtgoWWw4n1QnmDDkYqIdWWCNdt1L4JD9g6rwDgnsGsSV10bAnd5n7DQLHGWHjRex+zAbjXWT8t0g== +vue-virtual-scroll-list@^1.4.4: + version "1.4.4" + resolved "https://registry.yarnpkg.com/vue-virtual-scroll-list/-/vue-virtual-scroll-list-1.4.4.tgz#5fca7a13f785899bbfb70471ec4fe222437d8495" + integrity sha512-wU7FDpd9Xy4f62pf8SBg/ak21jMI/pdx4s4JPah+z/zuhmeAafQgp8BjtZvvt+b0BZOsOS1FJuCfUH7azTkivQ== vue@^2.6.10: version "2.6.10" @@ -11879,10 +11903,10 @@ webpack-stats-plugin@^0.3.0: resolved "https://registry.yarnpkg.com/webpack-stats-plugin/-/webpack-stats-plugin-0.3.0.tgz#6952f63feb9a5393a328d774fb3eccac78d2f51b" integrity sha512-4a6mEl9HLtMukVjEPY8QPCSmtX2EDFJNhDTX5ZE2CLch2adKAZf53nUrpG6m7NattwigS0AodNcwNxlu9kMSDQ== -webpack@^4.40.2: - version "4.40.2" - resolved "https://registry.yarnpkg.com/webpack/-/webpack-4.40.2.tgz#d21433d250f900bf0facbabe8f50d585b2dc30a7" - integrity sha512-5nIvteTDCUws2DVvP9Qe+JPla7kWPPIDFZv55To7IycHWZ+Z5qBdaBYPyuXWdhggTufZkQwfIK+5rKQTVovm2A== +webpack@^4.41.5: + version "4.41.5" + resolved "https://registry.yarnpkg.com/webpack/-/webpack-4.41.5.tgz#3210f1886bce5310e62bb97204d18c263341b77c" + integrity sha512-wp0Co4vpyumnp3KlkmpM5LWuzvZYayDwM2n17EHFr4qxBBbRokC7DJawPJC7TfSFZ9HZ6GsdH40EBj4UV0nmpw== dependencies: "@webassemblyjs/ast" "1.8.5" "@webassemblyjs/helper-module-context" "1.8.5" @@ -11904,7 +11928,7 @@ webpack@^4.40.2: node-libs-browser "^2.2.1" schema-utils "^1.0.0" tapable "^1.1.3" - terser-webpack-plugin "^1.4.1" + terser-webpack-plugin "^1.4.3" watchpack "^1.6.0" webpack-sources "^1.4.1"